Files
traefikoidc/review_r148_test.go
T
lukaszraczylo 6d96e8869b test: add R-round regression and security suite
- fail-on-old regression tests for every shipped fix (R-rounds)
- fuzz targets (parseJWT, bearer), concurrency-stress, shuffle-flake guards
- coverage-closing contract tests (health-check backend, TLS-posture reuse)
- r154 ID-token fixture built from parts to avoid false-positive secret scan
2026-08-14 16:22:39 +01:00

130 lines
4.8 KiB
Go

package traefikoidc
import (
"strings"
"testing"
"time"
"github.com/gorilla/sessions"
)
func r148BaseConfig() *Config {
c := CreateConfig()
c.ProviderURL = "https://provider.example.com"
c.ClientID = "test-client"
c.ClientSecret = "test-secret"
c.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
c.CallbackURL = "/callback"
return c
}
// TestConfigValidates_ScopesRequireOpenID guards the R148 fix in
// settings.go Validate: scopes flow verbatim into the OIDC authorize
// request. An empty scope element (trailing comma in a Traefik label)
// and an override that drops the mandatory "openid" scope are rejected
// by strict OIDC providers at runtime (OIDC Core 3.1.2.1) — every
// login fails. Validate now rejects both at startup.
func TestConfigValidates_ScopesRequireOpenID(t *testing.T) {
empty := r148BaseConfig()
empty.Scopes = []string{"profile", ""}
if err := empty.Validate(); err == nil {
t.Fatal("empty scope element must be rejected by Validate")
}
overrideNoOpenID := r148BaseConfig()
overrideNoOpenID.OverrideScopes = true
overrideNoOpenID.Scopes = []string{"profile"}
if err := overrideNoOpenID.Validate(); err == nil {
t.Fatal("overrideScopes without mandatory openid scope must be rejected by Validate")
}
ok := r148BaseConfig()
ok.Scopes = []string{"openid", "profile"}
if err := ok.Validate(); err != nil {
t.Fatalf("valid scopes must pass Validate, got: %v", err)
}
}
// TestConfigValidates_SecurityHeaderCRLF guards the R148 fix:
// SecurityHeader CORS lists and CustomHeaders are emitted verbatim into
// response headers; a CR/LF would split/corrupt the header. Validate
// now rejects CR/LF/NUL in these values.
func TestConfigValidates_SecurityHeaderCRLF(t *testing.T) {
cors := r148BaseConfig()
cors.SecurityHeaders = &SecurityHeadersConfig{}
cors.SecurityHeaders.CORSAllowedHeaders = []string{"X-Auth\r\nInjected: 1"}
if err := cors.Validate(); err == nil {
t.Fatal("CORSAllowedHeaders entry with CR must be rejected by Validate")
}
custom := r148BaseConfig()
custom.SecurityHeaders = &SecurityHeadersConfig{}
custom.SecurityHeaders.CustomHeaders = map[string]string{"X-Custom": "a\nb"}
if err := custom.Validate(); err == nil {
t.Fatal("CustomHeaders value with LF must be rejected by Validate")
}
ok := r148BaseConfig()
ok.SecurityHeaders = &SecurityHeadersConfig{}
ok.SecurityHeaders.CORSAllowedHeaders = []string{"X-Auth"}
if err := ok.Validate(); err != nil {
t.Fatalf("clean security headers must pass Validate, got: %v", err)
}
}
// TestConfigValidates_HeaderName guard the R148 fix: a header Name
// with characters illegal in an HTTP field name would be written
// verbatim into every response line by Go's server, corrupting it.
func TestConfigValidates_HeaderName(t *testing.T) {
bad := r148BaseConfig()
bad.Headers = []TemplatedHeader{{Name: "X User Email", Value: "[email protected]"}}
if err := bad.Validate(); err == nil {
t.Fatal("header name with space must be rejected by Validate")
}
ok := r148BaseConfig()
ok.Headers = []TemplatedHeader{{Name: "X-User-Email", Value: "[email protected]"}}
if err := ok.Validate(); err != nil {
t.Fatalf("valid header name must pass Validate, got: %v", err)
}
}
// TestVerify_JTIBlacklistPiercesCache guard the R148 fix in
// verifyTokenWithOpts: the cached fast-return must still reject a token
// whose JTI has been blacklisted. A same-JTI rotated token that
// happens to be cached would otherwise keep returning "valid" until the
// cache TTL even though its JTI was revoked under a different raw
// value.
func TestVerify_JTIBlacklistPiercesCache(t *testing.T) {
oidc := makeBearerOIDC(t, nil)
// makeBearerOIDC leaves tokenBlacklist nil; wire an isolated one.
oidc.tokenBlacklist = newMapCache()
token := "aaa.bbb.ccc" // not the test-key prefix; 2 dots, len >= 10
seedVerified(t, oidc, token, map[string]interface{}{"sub": "u", "jti": "shared-jti"})
oidc.tokenBlacklist.Set("shared-jti", time.Now().Unix(), time.Minute)
if err := oidc.verifyTokenWithOpts(token, verifyOpts{}); err == nil {
t.Fatal("cached token whose JTI is blacklisted must be rejected (fast path must pierce jti)")
}
}
// TestChunk_NoIndexZeroNoPanic guards the R148 fix in
// processChunkedToken: a sparse chunk set without index 0 previously
// nil-dereferenced chunks[0].Now it must return a clean missing-chunk
// error instead of panicking.
func TestChunk_NoIndexZeroNoPanic(t *testing.T) {
cm := NewChunkManager(newNoOpLogger())
chunks := map[int]*sessions.Session{
1: {Values: map[interface{}]interface{}{"token_chunk": "part1", "token_total": 1}},
}
res := cm.processChunkedToken(chunks, RefreshTokenConfig)
if res.Error == nil {
t.Fatal("sparse chunk set missing index 0 must return an error, not a token")
}
if !strings.Contains(res.Error.Error(), "missing") {
t.Fatalf("expected missing-chunk error, got: %v", res.Error)
}
}