mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-20 04:57:02 +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,153 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R96 regression: backchannel/front-channel logout must invalidate sessions in
|
||||
// distributed (serializing-backend) deployments too. The Redis backend JSON
|
||||
// round-trips the stored unix-second int64 as a float64, so the previous
|
||||
// strict `val.(int64)` assertion always failed and isSessionInvalidated
|
||||
// returned false — the user stayed authenticated after IdP-initiated logout
|
||||
// whenever sessionInvalidationCache was backed by Redis.
|
||||
func TestSessionInvalidationReadsFloat64FromSerializingBackend(t *testing.T) {
|
||||
oidc := &TraefikOidc{
|
||||
logger: NewLogger("error"),
|
||||
sessionInvalidationCache: &mockCacheInterface{data: map[string]interface{}{}},
|
||||
}
|
||||
sid, sub := "session-123", "subject-9"
|
||||
now := time.Now()
|
||||
cache := oidc.sessionInvalidationCache
|
||||
// Simulate the Redis round-trip: invalidateSession stores time.Now().Unix()
|
||||
// (int64), but the serializing backend returns float64 on Get.
|
||||
cache.Set(oidc.buildSessionInvalidationKey("sid", sid), float64(now.Unix()), time.Hour)
|
||||
|
||||
// Session created before the invalidation must be considered invalid (by sid).
|
||||
if !oidc.isSessionInvalidated(sid, "", now.Add(-time.Minute)) {
|
||||
t.Fatalf("expected session to be invalidated when cache returns float64 (Redis)")
|
||||
}
|
||||
|
||||
// By-subject invalidation must also work with the same float64 round-trip.
|
||||
cache.Set(oidc.buildSessionInvalidationKey("sub", sub), float64(now.Unix()), time.Hour)
|
||||
if !oidc.isSessionInvalidated("", sub, now.Add(-time.Minute)) {
|
||||
t.Fatalf("expected subject to be invalidated when cache returns float64 (Redis)")
|
||||
}
|
||||
}
|
||||
|
||||
// sessionInvalidationTime must accept every representation a cache backend may
|
||||
// return: raw int64 (in-memory), float64 (JSON round-trip), json.Number.
|
||||
func TestSessionInvalidationTimeAcceptsAllBackendTypes(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
cases := []struct {
|
||||
name string
|
||||
val interface{}
|
||||
want int64
|
||||
wantOK bool
|
||||
}{
|
||||
{"int64 (local backend)", now, now, true},
|
||||
{"float64 (redis round-trip)", float64(now), now, true},
|
||||
{"json.Number", json.Number("1234567890"), 1234567890, true},
|
||||
{"unsupported type", "not-a-timestamp", 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := sessionInvalidationTime(tc.val)
|
||||
if ok != tc.wantOK {
|
||||
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
|
||||
}
|
||||
if tc.wantOK && got != tc.want {
|
||||
t.Fatalf("got %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// R96 P2-1 regression: a JSON-null group claim must be treated as an empty
|
||||
// list, not a hard error. Previously a null group claim made the extraction
|
||||
// fail, and with allowedRolesAndGroups set a valid holder of an allowed role
|
||||
// was 403'd before the gate could see that role.
|
||||
func TestNullGroupClaimStillAllowsRoleAuth(t *testing.T) {
|
||||
oidc := &TraefikOidc{
|
||||
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
||||
logger: NewLogger("error"),
|
||||
groupClaimName: "groups",
|
||||
roleClaimName: "roles",
|
||||
allowedRolesAndGroups: map[string]struct{}{"admin": {}},
|
||||
minimalHeaders: false, // R173: minimal mode now drops X-User-Roles; keep default mode to test the sibling-interaction
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
// groups present but null (decodes to nil); the allowed value is in roles.
|
||||
p := &principal{Identifier: "u", Claims: map[string]interface{}{"sub": "s", "groups": nil, "roles": []string{"admin"}}}
|
||||
oidc.forwardAuthorized(rw, req, p)
|
||||
|
||||
if rw.Code == http.StatusForbidden {
|
||||
t.Fatalf("null group claim caused a 403 despite an allowed role")
|
||||
}
|
||||
if got := req.Header.Get("X-User-Roles"); got != "admin" {
|
||||
t.Fatalf("expected X-User-Roles set to allowed role, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// R96 P2-2 regression: a malformed claim in one signal (e.g. numeric
|
||||
// groups) must not suppress the valid sibling signal's header. Previously a
|
||||
// single bad claim set extractErr != nil, skipping BOTH header writes.
|
||||
func TestMalformedGroupDoesNotDropValidRoleHeader(t *testing.T) {
|
||||
oidc := &TraefikOidc{
|
||||
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
||||
logger: NewLogger("error"),
|
||||
groupClaimName: "groups",
|
||||
roleClaimName: "roles",
|
||||
minimalHeaders: false, // R173: minimal mode now drops X-User-Roles; keep default mode to test the sibling-interaction
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
// groups is malformed (numeric), roles is valid.
|
||||
p := &principal{Identifier: "u", Claims: map[string]interface{}{"sub": "s", "groups": float64(7), "roles": []string{"editor"}}}
|
||||
oidc.forwardAuthorized(rw, req, p)
|
||||
|
||||
if got := req.Header.Get("X-User-Roles"); got != "editor" {
|
||||
t.Fatalf("malformed groups claim dropped the valid roles header: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// R96 regression: the per-IP 429 must report the ACTUAL remaining penalty
|
||||
// (time until the penalty box expires), not the full configured penalty.
|
||||
// writeBearerError previously overwrote the real value with the full penalty.
|
||||
func TestWriteBearerErrorReportsActualRetryAfter(t *testing.T) {
|
||||
oidc := &TraefikOidc{bearerFailurePenalty: 60 * time.Second}
|
||||
actual := 5 * time.Second
|
||||
err := newBearerError(bearerErrThrottled, "ip in penalty box")
|
||||
err.retryAfter = actual
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
oidc.writeBearerError(rw, req, err)
|
||||
|
||||
if got := rw.Header().Get("Retry-After"); got != "5" {
|
||||
t.Fatalf("want Retry-After to reflect actual remaining penalty (5), got %q", got)
|
||||
}
|
||||
if rw.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("want 429, got %d", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// R96 regression: a ported r.Host must not defeat the subdomain-bounding
|
||||
// safeguard that keeps the token-bearing session cookie scoped to the serving
|
||||
// host. Previously host = "app.example.com:8080" made HasSuffix(host,
|
||||
// ".example.com") false, so the cookie Domain fell back to the strict
|
||||
// parent X-Forwarded-Host — scoping it to every sibling subdomain.
|
||||
func TestEnhanceSessionSecurity_StripPortBeforeSubdomainBound(t *testing.T) {
|
||||
sm := createTestSessionManager(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "https://app.example.com:8080/", nil)
|
||||
req.Host = "app.example.com:8080"
|
||||
req.Header.Set("X-Forwarded-Host", "example.com") // strict parent
|
||||
opts := sm.EnhanceSessionSecurity(nil, req)
|
||||
if opts.Domain != "app.example.com" {
|
||||
t.Fatalf("want Domain stayed bounded to serving host app.example.com, got %q", opts.Domain)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user