Skip to content

Commit 683bb4b

Browse files
committed
fix(finding): key repeated headers by occurrence, not by value
folding the value into the header key gave every multi-valued header a distinct key, but it also destabilized the single-valued volatile ones. Date rides on nearly every response, alongside ETag, Age, CF-Ray and X-Request-Id, so each scan minted a fresh key and the diff/notify layer reported them added+removed every run. Key is documented to be run-stable (finding.go:41), so that was the wrong axis. count occurrences of the header name instead. the two Set-Cookies still split, Date and friends stay put. the count is per name rather than the slice index: headers.go ranges over resp.Header, so a name's slice position is randomized per run while the order of values within one name is preserved.
1 parent befd27a commit 683bb4b

2 files changed

Lines changed: 84 additions & 5 deletions

File tree

internal/finding/finding.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ package finding
2020
import (
2121
"fmt"
2222
"sort"
23+
"strconv"
2324
"strings"
2425

2526
"github.com/projectdiscovery/nuclei/v3/pkg/output"
@@ -481,16 +482,34 @@ func flattenProbe(target string, r *scan.ProbeResult) []Finding {
481482

482483
func flattenHeaders(target string, rs []scan.HeaderResult) []Finding {
483484
out := make([]Finding, 0, len(rs))
485+
// a multi-valued header (Set-Cookie is the canonical case) emits one
486+
// HeaderResult per value, and keying on the name alone collapses every
487+
// value but the first onto one dedup Key. disambiguate by how many times
488+
// the name has been seen rather than by the value: the value would
489+
// destabilize every volatile single-valued header (Date, ETag, Age, CF-Ray)
490+
// against the run-stable Key contract.
491+
//
492+
// the count is per name, not the slice index. headers.go ranges over
493+
// resp.Header, so a name's position in the slice is randomized per run
494+
// while the order of values within one name is preserved.
495+
//
496+
// the separator is ":", which rfc7230 excludes from a header field-name, so
497+
// the suffix cannot collide with a real header. "#" would: it is a valid
498+
// tchar, so a header literally named "Foo#1" would take the key of the
499+
// second "Foo".
500+
seen := make(map[string]int, len(rs))
484501
for i := 0; i < len(rs); i++ {
485502
h := rs[i]
486-
// a multi-valued header (Set-Cookie is the canonical case) emits one
487-
// HeaderResult per value; the value must ride in the identifier or
488-
// every value but the first collapses onto one dedup Key.
503+
identifier := h.Name
504+
if n := seen[h.Name]; n > 0 {
505+
identifier += ":" + strconv.Itoa(n)
506+
}
507+
seen[h.Name]++
489508
out = append(out, Finding{
490509
Target: target,
491510
Module: "headers",
492511
Severity: sevRecon,
493-
Key: key("headers", h.Name+":"+h.Value),
512+
Key: key("headers", identifier),
494513
Title: h.Name,
495514
Raw: h.Value,
496515
})

internal/finding/finding_test.go

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ func TestFlattenStableKeysAndSeverities(t *testing.T) {
314314
name: "header is recon info",
315315
value: scan.HeaderResults{{Name: "Server", Value: "nginx"}},
316316
module: "headers",
317-
wantKey: "headers:Server:nginx",
317+
wantKey: "headers:Server",
318318
wantSev: SeverityInfo,
319319
},
320320
{
@@ -396,6 +396,66 @@ func TestMultiValuedHeaderGetsDistinctKeys(t *testing.T) {
396396
}
397397
}
398398

399+
// Key is documented to be run-stable, so a header whose value changes every
400+
// response (Date is on nearly all of them, plus ETag/Age/CF-Ray/X-Request-Id)
401+
// must keep the same Key across scans. folding the value into the key would
402+
// churn a fresh key each run and make the diff/notify layer report every such
403+
// header as added+removed on every scan of every target.
404+
func TestVolatileHeaderKeyIsRunStable(t *testing.T) {
405+
run1 := Flatten(target, "headers", []scan.HeaderResult{{Name: "Date", Value: "Mon, 07 Jul 2026 12:00:00 GMT"}})
406+
run2 := Flatten(target, "headers", []scan.HeaderResult{{Name: "Date", Value: "Mon, 07 Jul 2026 12:00:01 GMT"}})
407+
if run1[0].Key != run2[0].Key {
408+
t.Fatalf("Date key churned across runs: %q then %q", run1[0].Key, run2[0].Key)
409+
}
410+
}
411+
412+
// the slice position of a header is randomized per run, so the disambiguator
413+
// must count occurrences within a name rather than use the slice index.
414+
func TestHeaderKeyIndependentOfEmissionOrder(t *testing.T) {
415+
cookieA := scan.HeaderResult{Name: "Set-Cookie", Value: "session=aaa"}
416+
cookieB := scan.HeaderResult{Name: "Set-Cookie", Value: "tracking=bbb"}
417+
server := scan.HeaderResult{Name: "Server", Value: "nginx"}
418+
date := scan.HeaderResult{Name: "Date", Value: "Mon, 07 Jul 2026 12:00:00 GMT"}
419+
420+
keysFor := func(rs []scan.HeaderResult) map[string]string {
421+
got := make(map[string]string)
422+
for _, f := range Flatten(target, "headers", rs) {
423+
got[f.Raw] = f.Key
424+
}
425+
return got
426+
}
427+
428+
// same headers, the map handed them to us in a different order.
429+
first := keysFor([]scan.HeaderResult{server, cookieA, cookieB, date})
430+
second := keysFor([]scan.HeaderResult{date, cookieA, cookieB, server})
431+
432+
for value, key := range first {
433+
if second[value] != key {
434+
t.Errorf("header %q key changed with emission order: %q then %q", value, key, second[value])
435+
}
436+
}
437+
if first["session=aaa"] == first["tracking=bbb"] {
438+
t.Fatalf("the two Set-Cookie values still share key %q", first["session=aaa"])
439+
}
440+
}
441+
442+
// the occurrence suffix must use a separator that cannot appear in a header
443+
// field-name, or it collides with a real header of that literal name.
444+
func TestHeaderOccurrenceSuffixCannotCollide(t *testing.T) {
445+
fs := Flatten(target, "headers", []scan.HeaderResult{
446+
{Name: "Foo", Value: "first"},
447+
{Name: "Foo", Value: "second"},
448+
{Name: "Foo#1", Value: "a genuinely different header"},
449+
})
450+
seen := make(map[string]string, len(fs))
451+
for _, f := range fs {
452+
if prev, dup := seen[f.Key]; dup {
453+
t.Errorf("key collision on %q: %q and %q", f.Key, prev, f.Raw)
454+
}
455+
seen[f.Key] = f.Raw
456+
}
457+
}
458+
399459
func TestJSEnvVarOrderingIsStable(t *testing.T) {
400460
res := &js.JavascriptScanResult{
401461
FoundEnvironmentVars: map[string]string{

0 commit comments

Comments
 (0)