Skip to content

Commit af86050

Browse files
committed
fix(frameworks): match cdn vendor markers on header presence
the vendor markers (cf-ray, x-amz-cf-id, x-vercel-id and friends) carried no Header, so MatchSignatures ran them through containsHeader, which substring matches every header name and every header value. an origin that merely names those headers in a value was read as sitting behind that edge: Access-Control-Expose-Headers: CF-Ray, X-Amz-Cf-Id, X-Vercel-Id -> "Cloudflare" at 0.9526 on a plain nginx origin that header is about the most common cors config there is, and cdns themselves emit it, so this was a live false positive rather than a corner. add Signature.Presence: with Header set, the signature matches on the header existing rather than on its value. that is the right primitive for an edge marker, whose value is an opaque request id with nothing to match. the brand words keep their existing Server/Via scoping.
1 parent 34c746c commit af86050

3 files changed

Lines changed: 44 additions & 13 deletions

File tree

internal/scan/frameworks/detector.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ type Signature struct {
3333
// Header, when set, scopes a HeaderOnly match to the named header's value
3434
// (canonical form, e.g. "X-Powered-By"). Empty matches across all headers.
3535
Header string
36+
// Presence, with Header set, matches on the header existing at all rather
37+
// than on its value. an edge marker like CF-Ray carries an opaque id, so
38+
// there is no value to match; what identifies the provider is that the
39+
// header was stamped. Pattern is unused.
40+
Presence bool
3641
}
3742

3843
// Detector is the interface for framework detection plugins.
@@ -111,9 +116,12 @@ func (b BaseDetector) MatchSignatures(body string, headers http.Header) float32
111116

112117
if sig.HeaderOnly {
113118
var matched bool
114-
if sig.Header != "" {
119+
switch {
120+
case sig.Presence:
121+
matched = len(headers.Values(sig.Header)) > 0
122+
case sig.Header != "":
115123
matched = headerValueContains(headers, sig.Header, sig.Pattern)
116-
} else {
124+
default:
117125
matched = containsHeader(headers, sig.Pattern)
118126
}
119127
if matched {

internal/scan/frameworks/detectors/cdn.go

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,17 @@ func init() {
3636
fw.RegisterCDN(&netlifyDetector{})
3737
}
3838

39-
// all CDN signatures are HeaderOnly and scoped to the specific edge-injected
40-
// header the provider controls, never a bare brand substring: a page that
41-
// merely mentions "cloudflare" in its body (a badge, a blog post, a status
42-
// widget) must not fire, only the response the edge itself stamped.
39+
// all CDN signatures are HeaderOnly and scoped to a specific header the
40+
// provider controls, never a bare substring matched across every header.
41+
//
42+
// vendor markers match on Presence, because the value is an opaque request id
43+
// and what identifies the provider is that the header was stamped at all. that
44+
// also keeps them off header values: an origin advertising
45+
// "Access-Control-Expose-Headers: CF-Ray" is naming a header, not sitting
46+
// behind cloudflare.
47+
//
48+
// brand words are scoped to the header that carries them (Server, Via), so a
49+
// CSP or Link header referencing a cdn-hosted asset cannot fire them.
4350

4451
type cloudflareDetector struct{}
4552

@@ -48,7 +55,7 @@ func (d *cloudflareDetector) Name() string { return "Cloudflare" }
4855
func (d *cloudflareDetector) Signatures() []fw.Signature {
4956
return []fw.Signature{
5057
// header name cf-ray is injected by every Cloudflare-proxied response.
51-
{Pattern: "cf-ray", Weight: 0.6, HeaderOnly: true},
58+
{Header: "CF-Ray", Presence: true, Weight: 0.6, HeaderOnly: true},
5259
{Pattern: "cloudflare", Weight: 0.4, HeaderOnly: true, Header: "Server"},
5360
}
5461
}
@@ -67,7 +74,7 @@ func (d *fastlyDetector) Signatures() []fw.Signature {
6774
// x-fastly-request-id is only emitted by fastly's own edge; a plain
6875
// varnish deployment (fastly is varnish-based) never sends it, so this
6976
// stays clear of the generic "Via: 1.1 varnish" false positive.
70-
{Pattern: "x-fastly-request-id", Weight: 0.6, HeaderOnly: true},
77+
{Header: "X-Fastly-Request-ID", Presence: true, Weight: 0.6, HeaderOnly: true},
7178
{Pattern: "fastly", Weight: 0.4, HeaderOnly: true, Header: "Via"},
7279
}
7380
}
@@ -83,8 +90,8 @@ func (d *akamaiDetector) Name() string { return "Akamai" }
8390

8491
func (d *akamaiDetector) Signatures() []fw.Signature {
8592
return []fw.Signature{
86-
{Pattern: "akamai-grn", Weight: 0.5, HeaderOnly: true},
87-
{Pattern: "x-akamai", Weight: 0.5, HeaderOnly: true},
93+
{Header: "Akamai-GRN", Presence: true, Weight: 0.5, HeaderOnly: true},
94+
{Header: "X-Akamai-Transformed", Presence: true, Weight: 0.5, HeaderOnly: true},
8895
}
8996
}
9097

@@ -99,7 +106,7 @@ func (d *cloudfrontDetector) Name() string { return "Amazon CloudFront" }
99106

100107
func (d *cloudfrontDetector) Signatures() []fw.Signature {
101108
return []fw.Signature{
102-
{Pattern: "x-amz-cf-id", Weight: 0.5, HeaderOnly: true},
109+
{Header: "X-Amz-Cf-Id", Presence: true, Weight: 0.5, HeaderOnly: true},
103110
{Pattern: "cloudfront", Weight: 0.5, HeaderOnly: true, Header: "Via"},
104111
}
105112
}
@@ -115,7 +122,7 @@ func (d *vercelDetector) Name() string { return "Vercel" }
115122

116123
func (d *vercelDetector) Signatures() []fw.Signature {
117124
return []fw.Signature{
118-
{Pattern: "x-vercel-id", Weight: 0.5, HeaderOnly: true},
125+
{Header: "X-Vercel-Id", Presence: true, Weight: 0.5, HeaderOnly: true},
119126
{Pattern: "vercel", Weight: 0.5, HeaderOnly: true, Header: "Server"},
120127
}
121128
}
@@ -131,7 +138,7 @@ func (d *netlifyDetector) Name() string { return "Netlify" }
131138

132139
func (d *netlifyDetector) Signatures() []fw.Signature {
133140
return []fw.Signature{
134-
{Pattern: "x-nf-request-id", Weight: 0.5, HeaderOnly: true},
141+
{Header: "X-Nf-Request-Id", Presence: true, Weight: 0.5, HeaderOnly: true},
135142
{Pattern: "netlify", Weight: 0.5, HeaderOnly: true, Header: "Server"},
136143
}
137144
}

internal/scan/frameworks/detectors/cdn_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,19 @@ func TestCDNDetectsRealEdgeHeaders(t *testing.T) {
159159
})
160160
}
161161
}
162+
163+
// an origin advertising which of its headers CORS clients may read is naming
164+
// header names in a header VALUE, not sitting behind that edge. matching a
165+
// vendor marker across every header value turns the most common CORS config
166+
// into a cdn detection, so those markers match on header presence instead.
167+
func TestCDNIgnoresVendorHeaderNamesInValues(t *testing.T) {
168+
origin := http.Header{}
169+
origin.Set("Server", "nginx/1.24.0")
170+
origin.Set("Access-Control-Expose-Headers",
171+
"CF-Ray, X-Amz-Cf-Id, X-Vercel-Id, X-Fastly-Request-Id, X-NF-Request-ID, Akamai-GRN")
172+
origin.Set("Vary", "Accept-Encoding, X-Akamai-Transformed")
173+
174+
if got := fw.DetectCDN("<html></html>", origin); got != nil {
175+
t.Errorf("plain nginx origin reported as %q (confidence %.4f) from vendor header names quoted in a value", got.Name, got.Confidence)
176+
}
177+
}

0 commit comments

Comments
 (0)