Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/examples/auth/apikey/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
package main

import (
"crypto/subtle"
"log"
"net/http"
"os"
Expand Down Expand Up @@ -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")
Expand Down
6 changes: 5 additions & 1 deletion docs/examples/auth/basic/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ package main

import (
"context"
"crypto/subtle"
"net/http"

"github.com/go-openapi/errors"
Expand All @@ -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")
Expand Down
61 changes: 61 additions & 0 deletions headers_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -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
})
}
32 changes: 26 additions & 6 deletions security/authenticator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
170 changes: 170 additions & 0 deletions server-middleware/mediatype/fuzz_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
Loading
Loading