mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-21 04:57:48 +00:00
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
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestServeHTTP_ExcludedURL_StripsIdentityHeaders regresses excluded-URL
|
||||
// (public path) bypass forwarding client-forged identity headers to the
|
||||
// backend. forwardAuthorized and the SSE/WebSocket bypass both strip the
|
||||
// owned identity headers so token-derived values are authoritative; the
|
||||
// excluded branch forwarded t.next.ServeHTTP without clearing them, so a
|
||||
// client could put X-Forwarded-User: admin on a public path (health,
|
||||
// metrics) and the backend would trust it (R102).
|
||||
func TestServeHTTP_ExcludedURL_StripsIdentityHeaders(t *testing.T) {
|
||||
excluded := map[string]struct{}{"/health": {}}
|
||||
|
||||
var forwarded http.Header
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
forwarded = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
oidc := &TraefikOidc{
|
||||
excludedURLs: excluded,
|
||||
next: next,
|
||||
logger: NewLogger("debug"),
|
||||
initComplete: make(chan struct{}),
|
||||
sessionManager: createTestSessionManager(t),
|
||||
firstRequestStarted: 1,
|
||||
metadataRefreshStartedAtomic: 1,
|
||||
issuerURL: "https://provider.example.com",
|
||||
}
|
||||
close(oidc.initComplete)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
req.Header.Set("X-Forwarded-User", "forged-admin")
|
||||
req.Header.Set("X-User-Groups", "forged-admins")
|
||||
req.Header.Set("X-User-Roles", "forged-super")
|
||||
req.Header.Set("X-Auth-Request-Token", "forged-token")
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
for _, h := range identityHeaders {
|
||||
if v := forwarded.Get(h); v != "" {
|
||||
t.Errorf("header %q = %q reached backend through excluded URL; want empty (forged value)", h, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type recordingCache struct {
|
||||
mu sync.Mutex
|
||||
ttls map[string]time.Duration
|
||||
}
|
||||
|
||||
func (r *recordingCache) Set(k string, v any, ttl time.Duration) {
|
||||
r.mu.Lock()
|
||||
r.ttls[k] = ttl
|
||||
r.mu.Unlock()
|
||||
}
|
||||
func (r *recordingCache) Get(k string) (any, bool) { return nil, false }
|
||||
func (r *recordingCache) Delete(k string) {}
|
||||
func (r *recordingCache) SetMaxSize(int) {}
|
||||
func (r *recordingCache) Size() int { return 0 }
|
||||
func (r *recordingCache) Clear() {}
|
||||
func (r *recordingCache) Cleanup() {}
|
||||
func (r *recordingCache) Close() {}
|
||||
func (r *recordingCache) GetStats() map[string]any { return nil }
|
||||
|
||||
func makeJWTForTest(claims map[string]any) string {
|
||||
h, _ := json.Marshal(map[string]any{"alg": "none", "typ": "JWT"})
|
||||
p, _ := json.Marshal(claims)
|
||||
return base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(p) + ".c2ln"
|
||||
}
|
||||
|
||||
// TestRevokeToken_BlacklistDurationCoversTokenExp regresses RevokeToken
|
||||
// hardcoding a 24h blacklist TTL and ignoring the token's exp claim. A
|
||||
// revoked token stays cryptographically valid until exp, so with exp > 24h
|
||||
// away it became re-presentable (and re-cacheable) after 24h. The
|
||||
// blacklist TTL must extend to cover the token's full validity window.
|
||||
func TestRevokeToken_BlacklistDurationCoversTokenExp(t *testing.T) {
|
||||
bl := &recordingCache{ttls: map[string]time.Duration{}}
|
||||
oidc := &TraefikOidc{tokenBlacklist: bl, tokenCache: NewTokenCache(), logger: NewLogger("debug")}
|
||||
|
||||
// Long-lived token: exp 72h away. Blacklist must outlast 24h for both
|
||||
// the raw token and its jti.
|
||||
now := time.Now()
|
||||
longToken := makeJWTForTest(map[string]any{"exp": float64(now.Add(72 * time.Hour).Unix()), "jti": "jt-long"})
|
||||
oidc.RevokeToken(longToken)
|
||||
if d := bl.ttls[longToken]; d <= 24*time.Hour {
|
||||
t.Fatalf("blacklist TTL for exp=72h token = %v, want > 24h", d)
|
||||
}
|
||||
if d := bl.ttls["jt-long"]; d <= 24*time.Hour {
|
||||
t.Fatalf("blacklist TTL for jti of exp=72h token = %v, want > 24h", d)
|
||||
}
|
||||
|
||||
// Token already expiring soon: 24h floor still covers it.
|
||||
shortToken := makeJWTForTest(map[string]any{"exp": float64(now.Add(6 * time.Hour).Unix())})
|
||||
oidc.RevokeToken(shortToken)
|
||||
if d := bl.ttls[shortToken]; d < 24*time.Hour {
|
||||
t.Fatalf("blacklist TTL for exp=6h token = %v, want >= 24h floor", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimScalarString regresses user-identifier extraction doing a strict
|
||||
// .(string) assertion so a numeric identifier claim (e.g. a numeric sub
|
||||
// from a non-conformant IdP) was silently dropped -> spurious 500/401 for
|
||||
// an otherwise valid principal. Scalar numbers are now coerced to their
|
||||
// decimal string form; non-scalars stay invalid.
|
||||
func TestClaimScalarString(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{name: "string", in: "[email protected]", want: "[email protected]", ok: true},
|
||||
{name: "empty string", in: "", want: "", ok: true},
|
||||
{name: "float64", in: float64(123456), want: "123456", ok: true},
|
||||
{name: "json.Number", in: json.Number("987654"), want: "987654", ok: true},
|
||||
{name: "int", in: 42, want: "42", ok: true},
|
||||
{name: "int64", in: int64(777), want: "777", ok: true},
|
||||
{name: "map", in: map[string]any{"x": 1}, want: "", ok: false},
|
||||
{name: "slice", in: []any{"a"}, want: "", ok: false},
|
||||
{name: "nil", in: nil, want: "", ok: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := claimScalarString(tc.in)
|
||||
if ok != tc.ok || got != tc.want {
|
||||
t.Fatalf("claimScalarString(%v) = (%q, %v), want (%q, %v)", tc.in, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveBearerIdentifier_NumericClaim regresses the bearer path
|
||||
// rejecting a numeric identifier claim with 'not a string' -> 401 for a
|
||||
// valid principal. A numeric sub is now stringified and accepted.
|
||||
func TestResolveBearerIdentifier_NumericClaim(t *testing.T) {
|
||||
oidc := &TraefikOidc{logger: NewLogger("debug")}
|
||||
var _ = oidc
|
||||
|
||||
id, berr := resolveBearerIdentifier(map[string]any{"sub": float64(424242)}, "sub")
|
||||
if berr != nil {
|
||||
t.Fatalf("numeric sub should be accepted, got error: %v", berr.reason)
|
||||
}
|
||||
if id != "424242" {
|
||||
t.Fatalf("numeric sub = %q, want 424242", id)
|
||||
}
|
||||
|
||||
// non-scalar still rejected
|
||||
if _, berr := resolveBearerIdentifier(map[string]any{"sub": map[string]any{"a": 1}}, "sub"); berr == nil {
|
||||
t.Fatal("non-scalar sub should be rejected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user