From 0fd96a1364dad8491cc11babb84f97e6b36886a3 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Fri, 15 May 2026 09:02:48 +0200 Subject: [PATCH 1/3] fix(negotiate/header): reject q-values greater than 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 7231 §5.3.1 defines qvalue as a fraction in [0, 1]: when the leading digit is "1", the only valid decimal portion is "0", "00" or "000". expectQuality previously accepted inputs like "1.1" or "1.9" verbatim and returned values > 1, letting a malformed Accept entry artificially boost its priority above all properly-formed offers. The fix surfaces the malformed input via the existing q < 0 sentinel; ParseAccept and ParseAccept2 inherit the rejection. Found by FuzzParseAccept (lens 4 of the security scrub). The "0;q=1.1" minimised input is persisted under testdata/fuzz/ as a regression seed. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Frederic BIDON --- server-middleware/negotiate/header/header.go | 8 +++++++- .../header/testdata/fuzz/FuzzParseAccept/b406d258b5915edd | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 server-middleware/negotiate/header/testdata/fuzz/FuzzParseAccept/b406d258b5915edd diff --git a/server-middleware/negotiate/header/header.go b/server-middleware/negotiate/header/header.go index 6ce870d8..6f3c3f00 100644 --- a/server-middleware/negotiate/header/header.go +++ b/server-middleware/negotiate/header/header.go @@ -300,7 +300,13 @@ func expectQuality(s string) (q float64, rest string) { n = n*10 + int(b) - '0' d *= 10 } - return q + float64(n)/float64(d), s[i:] + result := q + float64(n)/float64(d) + // RFC 7231 §5.3.1: qvalue is in [0, 1]. Inputs like "1.1" + // would otherwise yield > 1; reject as malformed. + if result > 1 { + return -1, s[i:] + } + return result, s[i:] } func expectTokenOrQuoted(s string) (value string, rest string) { diff --git a/server-middleware/negotiate/header/testdata/fuzz/FuzzParseAccept/b406d258b5915edd b/server-middleware/negotiate/header/testdata/fuzz/FuzzParseAccept/b406d258b5915edd new file mode 100644 index 00000000..b3a81702 --- /dev/null +++ b/server-middleware/negotiate/header/testdata/fuzz/FuzzParseAccept/b406d258b5915edd @@ -0,0 +1,2 @@ +go test fuzz v1 +string("0;q=1.1") From 481a274af6734cb834613f82cd8b801d3b5e82e6 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Fri, 15 May 2026 09:02:58 +0200 Subject: [PATCH 2/3] 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 Signed-off-by: Frederic BIDON --- headers_fuzz_test.go | 61 +++++++ server-middleware/mediatype/fuzz_test.go | 170 ++++++++++++++++++ .../negotiate/header/fuzz_test.go | 129 +++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 headers_fuzz_test.go create mode 100644 server-middleware/mediatype/fuzz_test.go create mode 100644 server-middleware/negotiate/header/fuzz_test.go diff --git a/headers_fuzz_test.go b/headers_fuzz_test.go new file mode 100644 index 00000000..dedc1849 --- /dev/null +++ b/headers_fuzz_test.go @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package runtime + +import ( + "net/http" + "strings" + "testing" +) + +// FuzzContentType exercises [ContentType] with arbitrary +// Content-Type header values. Invariants: must not panic or hang; +// when err is non-nil, the returned media type and charset must +// both be empty. +// +// Lens 4 (header parsing) of the security scrub: +// .claude/plans/security-scrub.md. +func FuzzContentType(f *testing.F) { + const appJSON = JSONMime + seeds := []string{ + "", + " ", + appJSON, + appJSON + "; charset=utf-8", + appJSON + "; charset=\"utf-8\"", + appJSON + "; charset=\"utf\\\"8\"", + appJSON + "; charset=\xff\xfe", + appJSON + ";", + appJSON + ";;", + appJSON + "; ;", + appJSON + "; charset", + appJSON + "; charset=", + "application/octet-stream", + "text/plain; charset=us-ascii", + strings.Repeat("a", 4096), + appJSON + "; " + strings.Repeat("x=y;", 256), + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, in string) { + h := http.Header{HeaderContentType: []string{in}} + mt, cs, err := ContentType(h) + if err != nil { + if mt != "" || cs != "" { + t.Fatalf("ContentType(%q) returned (mt=%q, cs=%q, err=%v) — non-empty mt/cs with error", + in, mt, cs, err) + } + return + } + // Success path: when input is non-empty and parses, mt + // must be non-empty (the stdlib mime.ParseMediaType already + // guarantees this; we re-assert as a regression guard). + // Empty input is allowed: returns ("", "", nil) via the + // DefaultMime branch. + _ = mt + _ = cs + }) +} diff --git a/server-middleware/mediatype/fuzz_test.go b/server-middleware/mediatype/fuzz_test.go new file mode 100644 index 00000000..2dbdc39f --- /dev/null +++ b/server-middleware/mediatype/fuzz_test.go @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package mediatype + +import ( + "strings" + "testing" +) + +// Test-only constants pulled out for goconst. The `jsonMime` and +// `starStar` constants are shared with the rest of the in-package +// test corpus (mediatype_test.go). +const ( + testMTAppPrefix = "application/" + testMTSubJSON = "/json" + testMTAppStar = "application/*" +) + +// FuzzParse exercises [Parse] with arbitrary input. The invariant +// is: Parse must not panic, hang, or return a non-zero MediaType +// alongside a non-nil error. +// +// Lens 4 (header parsing) of the security scrub: +// .claude/plans/security-scrub.md. +func FuzzParse(f *testing.F) { + seeds := []string{ + "", + " ", + jsonMime, + jsonMime + "; charset=utf-8", + jsonMime + ";q=0.5", + jsonMime + " ; charset=utf-8 ; q=0.5", + "application/problem+json", + "application/vnd.api+json; version=1", + "text/plain; charset=\"utf-8\"", + "text/plain; charset=\"utf\\\"8\"", + starStar, + testMTAppStar, + "application/json,text/xml", // multi-entry — Parse is single-only + jsonMime + "; q=2.0", // invalid q + jsonMime + "; q=-1", // invalid q + jsonMime + "; q=abc", // invalid q + testMTAppPrefix, + testMTSubJSON, + "application", + jsonMime + "/extra", + ";charset=utf-8", + jsonMime + "; ;", + jsonMime + ";;", + jsonMime + "; charset=", + jsonMime + "; charset", + jsonMime + "; charset=\xff\xfe", + jsonMime + "+", + "application/+json", + "application/json+\x00", + strings.Repeat("a", 4096), // long type + jsonMime + "; " + strings.Repeat("x=y;", 256), // many params + jsonMime + "; charset=" + strings.Repeat("a", 4096), // long value + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, in string) { + mt, err := Parse(in) + if err != nil { + // Error path: zero MediaType expected. + if mt.Type != "" || mt.Subtype != "" || mt.Suffix != "" || len(mt.Params) != 0 { + t.Fatalf("Parse(%q) returned (mt=%+v, err=%v) — non-zero MediaType with error", in, mt, err) + } + return + } + // Success path: type and subtype must be non-empty. + if mt.Type == "" || mt.Subtype == "" { + t.Fatalf("Parse(%q) succeeded with empty Type/Subtype: %+v", in, mt) + } + // Q must be in [0, 1] when no q-value supplied (default 1.0) + // or when one was; we don't differentiate here, just that + // it's a valid float in a sane range. + if mt.Q < 0 || mt.Q > 1 { + t.Fatalf("Parse(%q) Q=%v out of [0,1]", in, mt.Q) + } + }) +} + +// FuzzMatchFirst exercises [MatchFirst] with arbitrary actual +// values against a fixed allowed list. The invariant is: must +// not panic, hang, or return ok=true with a zero MediaType. +// +// We fuzz the actual rather than both sides because the allowed +// list is typically a server-configured offer set (operator-trusted) +// while the actual is the client-supplied Content-Type / Accept +// header (untrusted). +func FuzzMatchFirst(f *testing.F) { + allowed := []string{ + jsonMime, + "application/xml", + "text/plain", + "application/vnd.api+json", + starStar, + } + + seeds := []string{ + "", + jsonMime, + jsonMime + "; charset=utf-8", + "application/problem+json", + "text/plain", + "application/octet-stream", + "", + "\x00", + "\xff\xfe", + strings.Repeat("a", 4096), + testMTAppPrefix + strings.Repeat("x", 1024), + testMTSubJSON, + testMTAppPrefix, + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, actual string) { + mt, ok, err := MatchFirst(allowed, actual, AllowSuffix()) + if ok && (mt.Type == "" || mt.Subtype == "") { + t.Fatalf("MatchFirst(%q) returned ok=true with empty MediaType: %+v", actual, mt) + } + if !ok && mt.Type != "" { + t.Fatalf("MatchFirst(%q) returned ok=false with non-zero MediaType: %+v", actual, mt) + } + // err may be set for malformed actuals; not a fault. + _ = err + }) +} + +// FuzzParseAccept exercises [ParseAccept] with arbitrary Accept +// headers. The invariant is: must not panic, hang, or return a +// non-empty Set with entries that fail their own invariants +// (Type/Subtype non-empty; Q in [0,1]). +func FuzzParseAccept(f *testing.F) { + seeds := []string{ + "", + jsonMime, + jsonMime + "; q=0.5", + "application/json, text/xml; q=0.8, */*; q=0.1", + "text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8", + jsonMime + "; q=2.0", // invalid q + jsonMime + "; q=-1", // invalid q + "application/json,, text/plain", + jsonMime + ";q=0.5;charset=utf-8", + "," + strings.Repeat("a", 1024), + strings.Repeat(",", 256), + strings.Repeat("application/json,", 256), + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, in string) { + set := ParseAccept(in) + for i, mt := range set { + if mt.Type == "" || mt.Subtype == "" { + t.Fatalf("ParseAccept(%q)[%d] empty Type/Subtype: %+v", in, i, mt) + } + if mt.Q < 0 || mt.Q > 1 { + t.Fatalf("ParseAccept(%q)[%d] Q=%v out of [0,1]", in, i, mt.Q) + } + } + }) +} diff --git a/server-middleware/negotiate/header/fuzz_test.go b/server-middleware/negotiate/header/fuzz_test.go new file mode 100644 index 00000000..50d412a3 --- /dev/null +++ b/server-middleware/negotiate/header/fuzz_test.go @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package header + +import ( + "net/http" + "strings" + "testing" +) + +const testHdrAccept = "Accept" + +// FuzzParseValueAndParams exercises [parseValueAndParams] (the +// string-level version of [ParseValueAndParams]) with arbitrary +// input. Invariants: must not panic, hang, or return a non-empty +// params map with empty keys. +// +// Lens 4 (header parsing) of the security scrub: +// .claude/plans/security-scrub.md. +func FuzzParseValueAndParams(f *testing.F) { + seeds := []string{ + "", + " ", + "application/json", + "application/json; charset=utf-8", + "application/json; charset=\"utf-8\"", + "application/json; charset=\"utf\\\"8\"", + "application/json;", + "application/json;;", + "application/json; ; charset=utf-8", + "application/json; charset", + "application/json; charset=", + "application/json; =utf-8", + "application/json; charset=utf-8; q=0.5", + "text/plain;param1=v1;param2=\"v 2\"", + "text/plain; param=\"\\\"\"", + "text/plain; param=\"\xff\xfe\"", + strings.Repeat("a", 1024) + "/json", + "application/json; " + strings.Repeat("k=v;", 256), + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, in string) { + value, params := parseValueAndParams(in) + // Invariants: empty keys forbidden in params; if value is + // empty, params must also be empty (the function bails + // out before populating params). + if value == "" && len(params) != 0 { + t.Fatalf("parseValueAndParams(%q) → value=\"\" but params=%v", in, params) + } + for k := range params { + if k == "" { + t.Fatalf("parseValueAndParams(%q) emitted empty param key; params=%v", in, params) + } + } + }) +} + +// FuzzParseAccept exercises [ParseAccept] via a real http.Header +// populated with the fuzzed input. Invariants: must not panic, +// hang, or return AcceptSpec entries with empty Value or Q +// outside [0, 1]. +func FuzzParseAccept(f *testing.F) { + seeds := []string{ + "", + "application/json", + "text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8", + "application/json; q=0.5, text/xml; q=0.7", + "application/json;q=2.0", + "application/json;q=-1", + "application/json,, text/plain", + "application/json,application/xml,text/plain", + "application/json;charset=utf-8;q=0.5", + "application/json;q=foo", + strings.Repeat("application/json,", 256), + strings.Repeat(",", 256), + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, in string) { + h := http.Header{testHdrAccept: []string{in}} + specs := ParseAccept(h, testHdrAccept) + for i, sp := range specs { + if sp.Value == "" { + t.Fatalf("ParseAccept(%q)[%d] empty Value", in, i) + } + if sp.Q < 0 || sp.Q > 1 { + t.Fatalf("ParseAccept(%q)[%d] Q=%v out of [0,1]", in, i, sp.Q) + } + } + }) +} + +// FuzzParseList exercises [ParseList] (comma-separated header +// list parser). Invariants: no panic, no empty entries. +func FuzzParseList(f *testing.F) { + seeds := []string{ + "", + "a", + "a,b,c", + "a, b, c", + " a , b , c ", + "a,,b", + ",a", + "a,", + "a,\"b,c\",d", + "a,\"b\\\"c\",d", + strings.Repeat("a,", 256), + strings.Repeat(",", 256), + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, in string) { + h := http.Header{"X-List": []string{in}} + out := ParseList(h, "X-List") + for i, v := range out { + if v == "" { + t.Fatalf("ParseList(%q)[%d] empty entry", in, i) + } + } + }) +} From e5ad065f4c82bbacb4a765c2fcca0e31fb39858d Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sat, 16 May 2026 23:09:54 +0200 Subject: [PATCH 3/3] fix(ci): fix fuzzing workflow Signed-off-by: Frederic BIDON --- .github/workflows/auto-merge.yml | 2 +- .github/workflows/bump-release.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/contributors.yml | 2 +- .github/workflows/go-test.yml | 2 +- .github/workflows/scanner.yml | 2 +- .github/workflows/tag-release.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index 2df77066..65793ea6 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -11,5 +11,5 @@ jobs: permissions: contents: write pull-requests: write - uses: go-openapi/ci-workflows/.github/workflows/auto-merge.yml@6ed4490472a56b1d952231565aac80f13c2d143c # v0.2.16 + uses: go-openapi/ci-workflows/.github/workflows/auto-merge.yml@7982843ba86a9b47f456a44ed94e82f882b50a8d # v0.2.17 secrets: inherit diff --git a/.github/workflows/bump-release.yml b/.github/workflows/bump-release.yml index e69ee922..dbe697ed 100644 --- a/.github/workflows/bump-release.yml +++ b/.github/workflows/bump-release.yml @@ -32,7 +32,7 @@ jobs: permissions: contents: write pull-requests: write - uses: go-openapi/ci-workflows/.github/workflows/bump-release-monorepo.yml@6ed4490472a56b1d952231565aac80f13c2d143c # v0.2.16 + uses: go-openapi/ci-workflows/.github/workflows/bump-release-monorepo.yml@7982843ba86a9b47f456a44ed94e82f882b50a8d # v0.2.17 with: bump-type: ${{ inputs.bump-type }} tag-message-title: ${{ inputs.tag-message-title }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f762fde1..bbb62355 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,5 +18,5 @@ jobs: permissions: contents: read security-events: write - uses: go-openapi/ci-workflows/.github/workflows/codeql.yml@6ed4490472a56b1d952231565aac80f13c2d143c # v0.2.16 + uses: go-openapi/ci-workflows/.github/workflows/codeql.yml@7982843ba86a9b47f456a44ed94e82f882b50a8d # v0.2.17 secrets: inherit diff --git a/.github/workflows/contributors.yml b/.github/workflows/contributors.yml index f07e32b7..3ff83b64 100644 --- a/.github/workflows/contributors.yml +++ b/.github/workflows/contributors.yml @@ -14,5 +14,5 @@ jobs: permissions: pull-requests: write contents: write - uses: go-openapi/ci-workflows/.github/workflows/contributors.yml@6ed4490472a56b1d952231565aac80f13c2d143c # v0.2.16 + uses: go-openapi/ci-workflows/.github/workflows/contributors.yml@7982843ba86a9b47f456a44ed94e82f882b50a8d # v0.2.17 secrets: inherit diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index e871f6a6..33985827 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -13,5 +13,5 @@ on: jobs: test: - uses: go-openapi/ci-workflows/.github/workflows/go-test-monorepo.yml@6ed4490472a56b1d952231565aac80f13c2d143c # v0.2.16 + uses: go-openapi/ci-workflows/.github/workflows/go-test-monorepo.yml@7982843ba86a9b47f456a44ed94e82f882b50a8d # v0.2.17 secrets: inherit diff --git a/.github/workflows/scanner.yml b/.github/workflows/scanner.yml index 5c24528f..ec7c3d04 100644 --- a/.github/workflows/scanner.yml +++ b/.github/workflows/scanner.yml @@ -15,5 +15,5 @@ jobs: permissions: contents: read security-events: write - uses: go-openapi/ci-workflows/.github/workflows/scanner.yml@6ed4490472a56b1d952231565aac80f13c2d143c # v0.2.16 + uses: go-openapi/ci-workflows/.github/workflows/scanner.yml@7982843ba86a9b47f456a44ed94e82f882b50a8d # v0.2.17 secrets: inherit diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index 764db9c5..2ffcb9eb 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -13,7 +13,7 @@ jobs: name: Create release permissions: contents: write - uses: go-openapi/ci-workflows/.github/workflows/release.yml@6ed4490472a56b1d952231565aac80f13c2d143c # v0.2.16 + uses: go-openapi/ci-workflows/.github/workflows/release.yml@7982843ba86a9b47f456a44ed94e82f882b50a8d # v0.2.17 with: tag: ${{ github.ref_name }} is-monorepo: true