Skip to content

Commit 8ba0180

Browse files
fredbiclaude
andcommitted
test(security): fuzz targets for header-parsing surface
Lands seven fuzz targets covering the header-parsing surface of the security scrub (lens 4): - runtime.ContentType - mediatype.Parse / mediatype.MatchFirst / mediatype.ParseAccept - negotiate/header.parseValueAndParams / ParseAccept / ParseList Each target carries a seed corpus of edge cases (malformed quoting, multi-byte sequences, oversized inputs, invalid q-values, trailing-semicolon and comma anomalies) plus per-target invariants (non-zero MediaType only on success; Q in [0,1]; non-empty params keys; no empty list entries). CI auto-discovers FuzzXxx via the shared go-test-monorepo workflow. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Frederic BIDON <fredbi@yahoo.com>
1 parent 106dd90 commit 8ba0180

3 files changed

Lines changed: 360 additions & 0 deletions

File tree

‎headers_fuzz_test.go‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package runtime
5+
6+
import (
7+
"net/http"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// FuzzContentType exercises [ContentType] with arbitrary
13+
// Content-Type header values. Invariants: must not panic or hang;
14+
// when err is non-nil, the returned media type and charset must
15+
// both be empty.
16+
//
17+
// Lens 4 (header parsing) of the security scrub:
18+
// .claude/plans/security-scrub.md.
19+
func FuzzContentType(f *testing.F) {
20+
const appJSON = JSONMime
21+
seeds := []string{
22+
"",
23+
" ",
24+
appJSON,
25+
appJSON + "; charset=utf-8",
26+
appJSON + "; charset=\"utf-8\"",
27+
appJSON + "; charset=\"utf\\\"8\"",
28+
appJSON + "; charset=\xff\xfe",
29+
appJSON + ";",
30+
appJSON + ";;",
31+
appJSON + "; ;",
32+
appJSON + "; charset",
33+
appJSON + "; charset=",
34+
"application/octet-stream",
35+
"text/plain; charset=us-ascii",
36+
strings.Repeat("a", 4096),
37+
appJSON + "; " + strings.Repeat("x=y;", 256),
38+
}
39+
for _, s := range seeds {
40+
f.Add(s)
41+
}
42+
43+
f.Fuzz(func(t *testing.T, in string) {
44+
h := http.Header{HeaderContentType: []string{in}}
45+
mt, cs, err := ContentType(h)
46+
if err != nil {
47+
if mt != "" || cs != "" {
48+
t.Fatalf("ContentType(%q) returned (mt=%q, cs=%q, err=%v) — non-empty mt/cs with error",
49+
in, mt, cs, err)
50+
}
51+
return
52+
}
53+
// Success path: when input is non-empty and parses, mt
54+
// must be non-empty (the stdlib mime.ParseMediaType already
55+
// guarantees this; we re-assert as a regression guard).
56+
// Empty input is allowed: returns ("", "", nil) via the
57+
// DefaultMime branch.
58+
_ = mt
59+
_ = cs
60+
})
61+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package mediatype
5+
6+
import (
7+
"strings"
8+
"testing"
9+
)
10+
11+
// Test-only constants pulled out for goconst. The `jsonMime` and
12+
// `starStar` constants are shared with the rest of the in-package
13+
// test corpus (mediatype_test.go).
14+
const (
15+
testMTAppPrefix = "application/"
16+
testMTSubJSON = "/json"
17+
testMTAppStar = "application/*"
18+
)
19+
20+
// FuzzParse exercises [Parse] with arbitrary input. The invariant
21+
// is: Parse must not panic, hang, or return a non-zero MediaType
22+
// alongside a non-nil error.
23+
//
24+
// Lens 4 (header parsing) of the security scrub:
25+
// .claude/plans/security-scrub.md.
26+
func FuzzParse(f *testing.F) {
27+
seeds := []string{
28+
"",
29+
" ",
30+
jsonMime,
31+
jsonMime + "; charset=utf-8",
32+
jsonMime + ";q=0.5",
33+
jsonMime + " ; charset=utf-8 ; q=0.5",
34+
"application/problem+json",
35+
"application/vnd.api+json; version=1",
36+
"text/plain; charset=\"utf-8\"",
37+
"text/plain; charset=\"utf\\\"8\"",
38+
starStar,
39+
testMTAppStar,
40+
"application/json,text/xml", // multi-entry — Parse is single-only
41+
jsonMime + "; q=2.0", // invalid q
42+
jsonMime + "; q=-1", // invalid q
43+
jsonMime + "; q=abc", // invalid q
44+
testMTAppPrefix,
45+
testMTSubJSON,
46+
"application",
47+
jsonMime + "/extra",
48+
";charset=utf-8",
49+
jsonMime + "; ;",
50+
jsonMime + ";;",
51+
jsonMime + "; charset=",
52+
jsonMime + "; charset",
53+
jsonMime + "; charset=\xff\xfe",
54+
jsonMime + "+",
55+
"application/+json",
56+
"application/json+\x00",
57+
strings.Repeat("a", 4096), // long type
58+
jsonMime + "; " + strings.Repeat("x=y;", 256), // many params
59+
jsonMime + "; charset=" + strings.Repeat("a", 4096), // long value
60+
}
61+
for _, s := range seeds {
62+
f.Add(s)
63+
}
64+
65+
f.Fuzz(func(t *testing.T, in string) {
66+
mt, err := Parse(in)
67+
if err != nil {
68+
// Error path: zero MediaType expected.
69+
if mt.Type != "" || mt.Subtype != "" || mt.Suffix != "" || len(mt.Params) != 0 {
70+
t.Fatalf("Parse(%q) returned (mt=%+v, err=%v) — non-zero MediaType with error", in, mt, err)
71+
}
72+
return
73+
}
74+
// Success path: type and subtype must be non-empty.
75+
if mt.Type == "" || mt.Subtype == "" {
76+
t.Fatalf("Parse(%q) succeeded with empty Type/Subtype: %+v", in, mt)
77+
}
78+
// Q must be in [0, 1] when no q-value supplied (default 1.0)
79+
// or when one was; we don't differentiate here, just that
80+
// it's a valid float in a sane range.
81+
if mt.Q < 0 || mt.Q > 1 {
82+
t.Fatalf("Parse(%q) Q=%v out of [0,1]", in, mt.Q)
83+
}
84+
})
85+
}
86+
87+
// FuzzMatchFirst exercises [MatchFirst] with arbitrary actual
88+
// values against a fixed allowed list. The invariant is: must
89+
// not panic, hang, or return ok=true with a zero MediaType.
90+
//
91+
// We fuzz the actual rather than both sides because the allowed
92+
// list is typically a server-configured offer set (operator-trusted)
93+
// while the actual is the client-supplied Content-Type / Accept
94+
// header (untrusted).
95+
func FuzzMatchFirst(f *testing.F) {
96+
allowed := []string{
97+
jsonMime,
98+
"application/xml",
99+
"text/plain",
100+
"application/vnd.api+json",
101+
starStar,
102+
}
103+
104+
seeds := []string{
105+
"",
106+
jsonMime,
107+
jsonMime + "; charset=utf-8",
108+
"application/problem+json",
109+
"text/plain",
110+
"application/octet-stream",
111+
"",
112+
"\x00",
113+
"\xff\xfe",
114+
strings.Repeat("a", 4096),
115+
testMTAppPrefix + strings.Repeat("x", 1024),
116+
testMTSubJSON,
117+
testMTAppPrefix,
118+
}
119+
for _, s := range seeds {
120+
f.Add(s)
121+
}
122+
123+
f.Fuzz(func(t *testing.T, actual string) {
124+
mt, ok, err := MatchFirst(allowed, actual, AllowSuffix())
125+
if ok && (mt.Type == "" || mt.Subtype == "") {
126+
t.Fatalf("MatchFirst(%q) returned ok=true with empty MediaType: %+v", actual, mt)
127+
}
128+
if !ok && mt.Type != "" {
129+
t.Fatalf("MatchFirst(%q) returned ok=false with non-zero MediaType: %+v", actual, mt)
130+
}
131+
// err may be set for malformed actuals; not a fault.
132+
_ = err
133+
})
134+
}
135+
136+
// FuzzParseAccept exercises [ParseAccept] with arbitrary Accept
137+
// headers. The invariant is: must not panic, hang, or return a
138+
// non-empty Set with entries that fail their own invariants
139+
// (Type/Subtype non-empty; Q in [0,1]).
140+
func FuzzParseAccept(f *testing.F) {
141+
seeds := []string{
142+
"",
143+
jsonMime,
144+
jsonMime + "; q=0.5",
145+
"application/json, text/xml; q=0.8, */*; q=0.1",
146+
"text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8",
147+
jsonMime + "; q=2.0", // invalid q
148+
jsonMime + "; q=-1", // invalid q
149+
"application/json,, text/plain",
150+
jsonMime + ";q=0.5;charset=utf-8",
151+
"," + strings.Repeat("a", 1024),
152+
strings.Repeat(",", 256),
153+
strings.Repeat("application/json,", 256),
154+
}
155+
for _, s := range seeds {
156+
f.Add(s)
157+
}
158+
159+
f.Fuzz(func(t *testing.T, in string) {
160+
set := ParseAccept(in)
161+
for i, mt := range set {
162+
if mt.Type == "" || mt.Subtype == "" {
163+
t.Fatalf("ParseAccept(%q)[%d] empty Type/Subtype: %+v", in, i, mt)
164+
}
165+
if mt.Q < 0 || mt.Q > 1 {
166+
t.Fatalf("ParseAccept(%q)[%d] Q=%v out of [0,1]", in, i, mt.Q)
167+
}
168+
}
169+
})
170+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package header
5+
6+
import (
7+
"net/http"
8+
"strings"
9+
"testing"
10+
)
11+
12+
const testHdrAccept = "Accept"
13+
14+
// FuzzParseValueAndParams exercises [parseValueAndParams] (the
15+
// string-level version of [ParseValueAndParams]) with arbitrary
16+
// input. Invariants: must not panic, hang, or return a non-empty
17+
// params map with empty keys.
18+
//
19+
// Lens 4 (header parsing) of the security scrub:
20+
// .claude/plans/security-scrub.md.
21+
func FuzzParseValueAndParams(f *testing.F) {
22+
seeds := []string{
23+
"",
24+
" ",
25+
"application/json",
26+
"application/json; charset=utf-8",
27+
"application/json; charset=\"utf-8\"",
28+
"application/json; charset=\"utf\\\"8\"",
29+
"application/json;",
30+
"application/json;;",
31+
"application/json; ; charset=utf-8",
32+
"application/json; charset",
33+
"application/json; charset=",
34+
"application/json; =utf-8",
35+
"application/json; charset=utf-8; q=0.5",
36+
"text/plain;param1=v1;param2=\"v 2\"",
37+
"text/plain; param=\"\\\"\"",
38+
"text/plain; param=\"\xff\xfe\"",
39+
strings.Repeat("a", 1024) + "/json",
40+
"application/json; " + strings.Repeat("k=v;", 256),
41+
}
42+
for _, s := range seeds {
43+
f.Add(s)
44+
}
45+
46+
f.Fuzz(func(t *testing.T, in string) {
47+
value, params := parseValueAndParams(in)
48+
// Invariants: empty keys forbidden in params; if value is
49+
// empty, params must also be empty (the function bails
50+
// out before populating params).
51+
if value == "" && len(params) != 0 {
52+
t.Fatalf("parseValueAndParams(%q) → value=\"\" but params=%v", in, params)
53+
}
54+
for k := range params {
55+
if k == "" {
56+
t.Fatalf("parseValueAndParams(%q) emitted empty param key; params=%v", in, params)
57+
}
58+
}
59+
})
60+
}
61+
62+
// FuzzParseAccept exercises [ParseAccept] via a real http.Header
63+
// populated with the fuzzed input. Invariants: must not panic,
64+
// hang, or return AcceptSpec entries with empty Value or Q
65+
// outside [0, 1].
66+
func FuzzParseAccept(f *testing.F) {
67+
seeds := []string{
68+
"",
69+
"application/json",
70+
"text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8",
71+
"application/json; q=0.5, text/xml; q=0.7",
72+
"application/json;q=2.0",
73+
"application/json;q=-1",
74+
"application/json,, text/plain",
75+
"application/json,application/xml,text/plain",
76+
"application/json;charset=utf-8;q=0.5",
77+
"application/json;q=foo",
78+
strings.Repeat("application/json,", 256),
79+
strings.Repeat(",", 256),
80+
}
81+
for _, s := range seeds {
82+
f.Add(s)
83+
}
84+
85+
f.Fuzz(func(t *testing.T, in string) {
86+
h := http.Header{testHdrAccept: []string{in}}
87+
specs := ParseAccept(h, testHdrAccept)
88+
for i, sp := range specs {
89+
if sp.Value == "" {
90+
t.Fatalf("ParseAccept(%q)[%d] empty Value", in, i)
91+
}
92+
if sp.Q < 0 || sp.Q > 1 {
93+
t.Fatalf("ParseAccept(%q)[%d] Q=%v out of [0,1]", in, i, sp.Q)
94+
}
95+
}
96+
})
97+
}
98+
99+
// FuzzParseList exercises [ParseList] (comma-separated header
100+
// list parser). Invariants: no panic, no empty entries.
101+
func FuzzParseList(f *testing.F) {
102+
seeds := []string{
103+
"",
104+
"a",
105+
"a,b,c",
106+
"a, b, c",
107+
" a , b , c ",
108+
"a,,b",
109+
",a",
110+
"a,",
111+
"a,\"b,c\",d",
112+
"a,\"b\\\"c\",d",
113+
strings.Repeat("a,", 256),
114+
strings.Repeat(",", 256),
115+
}
116+
for _, s := range seeds {
117+
f.Add(s)
118+
}
119+
120+
f.Fuzz(func(t *testing.T, in string) {
121+
h := http.Header{"X-List": []string{in}}
122+
out := ParseList(h, "X-List")
123+
for i, v := range out {
124+
if v == "" {
125+
t.Fatalf("ParseList(%q)[%d] empty entry", in, i)
126+
}
127+
}
128+
})
129+
}

0 commit comments

Comments
 (0)