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/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/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" 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) + } + } + }) +} 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")