From 872ed62a51f3b3e22cc4b0c28dcf81648969b681 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Fri, 15 May 2026 00:24:29 +0200 Subject: [PATCH 1/3] docs(security): document constant-time-comparison contract for auth callbacks The runtime delegates credential comparison to caller-supplied authentication callbacks (UserPassAuthentication, TokenAuthentication, ScopedTokenAuthentication, and their Ctx variants). Callers that compare a secret against a known value must use crypto/subtle.ConstantTimeCompare to avoid response-timing side-channels. This commit: - Documents the contract on all six callback type godocs. - Updates the apikey and basic auth examples to demonstrate the safe pattern, so users copying the snippets inherit it. No API change. The runtime itself does not compare secrets and remains structurally timing-safe. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Frederic BIDON --- docs/examples/auth/apikey/main.go | 5 ++++- docs/examples/auth/basic/main.go | 6 +++++- security/authenticator.go | 32 +++++++++++++++++++++++++------ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/docs/examples/auth/apikey/main.go b/docs/examples/auth/apikey/main.go index 30a79bcc..6e86a28b 100644 --- a/docs/examples/auth/apikey/main.go +++ b/docs/examples/auth/apikey/main.go @@ -11,6 +11,7 @@ package main import ( + "crypto/subtle" "log" "net/http" "os" @@ -40,7 +41,9 @@ func wireAPIKeyAuth() { api.RegisterAuth("key", security.APIKeyAuth( "X-Token", "header", func(token string) (any, error) { - if token == "abcdefuvwxyz" { + // Use subtle.ConstantTimeCompare to avoid leaking the + // expected token byte-by-byte via response timing. + if subtle.ConstantTimeCompare([]byte(token), []byte("abcdefuvwxyz")) == 1 { return "alice", nil } return nil, errors.New(http.StatusUnauthorized, "invalid api key") diff --git a/docs/examples/auth/basic/main.go b/docs/examples/auth/basic/main.go index a1ded2c5..fc0c238d 100644 --- a/docs/examples/auth/basic/main.go +++ b/docs/examples/auth/basic/main.go @@ -10,6 +10,7 @@ package main import ( "context" + "crypto/subtle" "net/http" "github.com/go-openapi/errors" @@ -33,7 +34,10 @@ type fakePrincipal struct{ Name string } type fakeStore struct{} func (fakeStore) AuthenticateBasic(_ context.Context, user, pass string) (*fakePrincipal, error) { - if user == "alice" && pass == "s3cret" { + // subtle.ConstantTimeCompare avoids leaking the expected password + // byte-by-byte via response timing. The username is non-secret and + // compared with `==` purely to short-circuit unknown accounts. + if user == "alice" && subtle.ConstantTimeCompare([]byte(pass), []byte("s3cret")) == 1 { return &fakePrincipal{Name: user}, nil } return nil, errors.Unauthenticated("basic") diff --git a/security/authenticator.go b/security/authenticator.go index 4c091018..2430997b 100644 --- a/security/authenticator.go +++ b/security/authenticator.go @@ -42,22 +42,42 @@ func ScopedAuthenticator(handler func(*ScopedAuthRequest) (bool, any, error)) ru }) } -// UserPassAuthentication authentication function. +// UserPassAuthentication validates a basic-auth credential. +// +// Implementations comparing the password (or any derived secret) against a +// known value MUST use [crypto/subtle.ConstantTimeCompare]: the runtime +// extracts the credential from the request and delegates the comparison +// here, and does not enforce a constant-time posture on the caller's behalf. type UserPassAuthentication func(string, string) (any, error) -// UserPassAuthenticationCtx authentication function with [context.Context]. +// UserPassAuthenticationCtx is the [context.Context]-aware variant of +// [UserPassAuthentication]. The same constant-time-comparison guidance +// applies. type UserPassAuthenticationCtx func(context.Context, string, string) (context.Context, any, error) -// TokenAuthentication authentication function. +// TokenAuthentication validates an API-key token. +// +// Implementations comparing the token against a known value MUST use +// [crypto/subtle.ConstantTimeCompare]; the runtime delegates the comparison +// here and does not enforce a constant-time posture on the caller's behalf. type TokenAuthentication func(string) (any, error) -// TokenAuthenticationCtx authentication function with [context.Context]. +// TokenAuthenticationCtx is the [context.Context]-aware variant of +// [TokenAuthentication]. The same constant-time-comparison guidance +// applies. type TokenAuthenticationCtx func(context.Context, string) (context.Context, any, error) -// ScopedTokenAuthentication authentication function. +// ScopedTokenAuthentication validates a bearer/OAuth2 token along with the +// scopes required for the operation. +// +// Implementations comparing the token against a known value MUST use +// [crypto/subtle.ConstantTimeCompare]; the runtime delegates the comparison +// here and does not enforce a constant-time posture on the caller's behalf. type ScopedTokenAuthentication func(string, []string) (any, error) -// ScopedTokenAuthenticationCtx authentication function with [context.Context]. +// ScopedTokenAuthenticationCtx is the [context.Context]-aware variant of +// [ScopedTokenAuthentication]. The same constant-time-comparison guidance +// applies. type ScopedTokenAuthenticationCtx func(context.Context, string, []string) (context.Context, any, error) var DefaultRealmName = "API" From 106dd909c14af4e5a16962244ba143c4a44873ec Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Fri, 15 May 2026 09:02:48 +0200 Subject: [PATCH 2/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 8ba01801e182034dfdce7c12fd8b8a12cda5ddcf Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Fri, 15 May 2026 09:02:58 +0200 Subject: [PATCH 3/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) + } + } + }) +}