Files
traefikoidc/review_r155_regression_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

167 lines
6.1 KiB
Go

package traefikoidc
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// R155 review-round regressions.
// TestR155_ServeHTTP_PanicAfterCommitKeepsBodyIntact guards the
// middleware panic-recovery fix (middleware.go). The deferred recover at
// the top of ServeHTTP used to WriteHeader(500) + Write("Internal
// Server Error") unconditionally. WriteHeader after commit is an
// idempotent no-op, but the Write appends to an already-emitted valid
// body - so a panic in the downstream handler after it had written a
// 200 + payload corrupted the response. The fix only writes the 500
// when nothing was committed yet.
// Fail-on-old: the committed 200 body gains a trailing
// "Internal Server Error", making the assertion below fail.
func TestR155_ServeHTTP_PanicAfterCommitKeepsBodyIntact(t *testing.T) {
var nextCalled bool
oidc := &TraefikOidc{
next: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("VALID PAYLOAD"))
panic("boom-after-commit")
}),
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
excludedURLs: map[string]struct{}{"/panic-excluded": {}},
securityHeadersApplier: func(rw http.ResponseWriter, req *http.Request) {
},
}
close(oidc.initComplete)
req := httptest.NewRequest("GET", "/panic-excluded", nil)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if !nextCalled {
t.Fatal("next handler was not reached")
}
if got := rw.Body.String(); got != "VALID PAYLOAD" {
t.Errorf("recover must not append to a committed 200 body, got %q", got)
}
if rw.Code != http.StatusOK {
t.Errorf("committed response must keep its status, got %d", rw.Code)
}
if strings.Contains(rw.Body.String(), "Internal Server Error") {
t.Errorf("committed body was corrupted by the panic handler: %q", rw.Body.String())
}
}
// TestR155_ScopeFilter_NoSupportedReturnsCopy guards the scope_filter
// aliasing fix. FilterSupportedScopes returned the caller's requested
// slice directly when the provider declared no supported scopes, so any
// later append/mutation on the returned slice shared (and mutated) the
// caller's backing array. The fix returns a copy.
// Fail-on-old: mutating result[0] also rewrites requested[0].
func TestR155_ScopeFilter_NoSupportedReturnsCopy(t *testing.T) {
filter := NewScopeFilter(&mockScopeFilterLogger{})
requested := []string{"openid", "profile"}
result := filter.FilterSupportedScopes(requested, []string{}, "https://provider.example.com")
if len(result) != len(requested) {
t.Fatalf("expected all requested scopes, got %v", result)
}
result[0] = "MUTATED"
if requested[0] != "openid" {
t.Errorf("returned slice aliases the caller's slice: mutating result rewrote requested to %q", requested[0])
}
}
// validInflatedIDToken returns a structurally-valid JWT whose encoded
// length is > headerTemplateMaxLen (8192): a proper header, a JSON
// payload with an exp claim plus a large random (non-repeating) claim,
// and a random signature. It must survive the ChunkManager's strict ID
// validation so SetIDToken/GetIDToken round-trip it unchanged.
func validInflatedIDToken(t *testing.T, fieldBytes int) string {
t.Helper()
hdr := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
big := make([]byte, fieldBytes)
if _, err := rand.Read(big); err != nil {
t.Fatal(err)
}
claims := map[string]interface{}{
"iss": "https://provider.example.com",
"sub": "[email protected]",
"aud": "test-client-id",
"exp": time.Now().Add(time.Hour).Unix(),
"big": base64.RawURLEncoding.EncodeToString(big),
}
pj, err := json.Marshal(claims)
if err != nil {
t.Fatal(err)
}
payload := base64.RawURLEncoding.EncodeToString(pj)
sg := make([]byte, 256)
if _, err := rand.Read(sg); err != nil {
t.Fatal(err)
}
sig := base64.RawURLEncoding.EncodeToString(sg)
return hdr + "." + payload + "." + sig
}
// TestR155_XAuthRequestToken_CappedAtHeaderBudget guards the
// X-Auth-Request-Token length cap (middleware.go). The header set the
// raw ID token with no bound, unlike every other header on the request,
// letting a large token push the request past 431 limits. The fix caps
// it at headerTemplateMaxLen (8192) like rendered template values.
// Fail-on-old: the full (uncapped) ID token is set, so the header is
// longer than 8192.
func TestR155_XAuthRequestToken_CappedAtHeaderBudget(t *testing.T) {
oidc := newR154TestPlugin(t)
// Ensure claim extraction returns a parseable map despite the large
// synthetic token, so the roles/header pipeline proceeds.
oidc.extractClaimsFunc = func(string) (map[string]interface{}, error) {
return map[string]interface{}{"email": "[email protected]"}, nil
}
full := validInflatedIDToken(t, 9000)
if len(full) <= headerTemplateMaxLen {
t.Fatalf("setup: expected an ID token larger than %d, got %d", headerTemplateMaxLen, len(full))
}
var captured http.Header
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = r.Header.Clone()
w.WriteHeader(http.StatusOK)
})
oidc.next = next
req := httptest.NewRequest("GET", "https://example.com/", nil)
session, err := oidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("failed to get session: %v", err)
}
session.SetUserIdentifier("[email protected]")
session.SetAuthenticated(true)
session.SetIDToken(full)
if got := session.GetIDToken(); got != full {
t.Fatalf("setup: ID token did not round-trip (len %d, want %d)", len(got), len(full))
}
oidc.processAuthorizedRequest(httptest.NewRecorder(), req, session, "https://example.com/callback")
h := captured.Get("X-Auth-Request-Token")
if len(h) != headerTemplateMaxLen {
t.Errorf("X-Auth-Request-Token must be capped at headerTemplateMaxLen, got len %d", len(h))
}
if !strings.HasPrefix(full, h) {
t.Errorf("capped header must be a prefix of the ID token")
}
}