mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-11-03 07:44:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
972993980e | ||
|
|
83da8e0bc2 | ||
|
|
fd3fbbdbab | ||
|
|
92c311fde1 | ||
|
|
2f0ee00e51 | ||
|
|
6e522fd212 | ||
|
|
3ce5f428cf | ||
|
|
dc4da7b4c9 | ||
|
|
2cb42aa485 | ||
|
|
9ecd987416 | ||
|
|
b1cf524bb4 | ||
|
|
eec076f812 | ||
|
|
ac0fcfbe6a | ||
|
|
78db86b455 | ||
|
|
621f7993a8 | ||
|
|
a1b7059da1 | ||
|
|
dae08b2c21 | ||
|
|
29a9b1f8f2 | ||
|
|
9ab7220828 |
@@ -0,0 +1,24 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only a real audience mismatch may select the lenient-audience path; an
|
||||||
|
// error text that merely embeds the attacker-chosen kid must not.
|
||||||
|
func TestAudienceErrorClassifiedBySentinel(t *testing.T) {
|
||||||
|
if !errors.Is(fmt.Errorf("standard claim verification failed: %w", verifyAudience("x", "y")), errInvalidAudience) {
|
||||||
|
t.Fatal("audience mismatch not recognized through wrapping")
|
||||||
|
}
|
||||||
|
if errors.Is(verifyAudience("y", "y"), errInvalidAudience) {
|
||||||
|
t.Fatal("matching audience classified as mismatch")
|
||||||
|
}
|
||||||
|
for _, kid := range []string{"audience", "invalid audience"} {
|
||||||
|
err := fmt.Errorf("failed to get public key: no matching public key found for kid: %s", kid)
|
||||||
|
if errors.Is(err, errInvalidAudience) {
|
||||||
|
t.Errorf("kid %q fooled the audience classifier", kid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-2
@@ -371,6 +371,11 @@ func enforceIatAge(claims map[string]interface{}, maxAge time.Duration) *bearerE
|
|||||||
if time.Since(iat) > maxAge {
|
if time.Since(iat) > maxAge {
|
||||||
return newBearerError(bearerErrInvalidToken, "token iat outside age bound")
|
return newBearerError(bearerErrInvalidToken, "token iat outside age bound")
|
||||||
}
|
}
|
||||||
|
// The opaque introspection path never runs jwt.Verify, so a future iat
|
||||||
|
// would otherwise defeat the age bound for the token's whole lifetime.
|
||||||
|
if time.Until(iat) > ClockSkewToleranceFuture {
|
||||||
|
return newBearerError(bearerErrInvalidToken, "token iat in the future")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,9 +558,10 @@ func clientIPForBearer(req *http.Request) string {
|
|||||||
}
|
}
|
||||||
host, _, err := net.SplitHostPort(req.RemoteAddr)
|
host, _, err := net.SplitHostPort(req.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return req.RemoteAddr
|
host = req.RemoteAddr
|
||||||
}
|
}
|
||||||
return host
|
// One IPv6 /64 shares a key, so a single host cannot mint 2^64 entries.
|
||||||
|
return limiterKey(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Bearer auth entrypoint ---
|
// --- Bearer auth entrypoint ---
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Addresses in one IPv6 /64 must share one failure-tracker key.
|
||||||
|
func TestClientIPForBearerCollapsesIPv6Prefix(t *testing.T) {
|
||||||
|
key := func(addr string) string {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.RemoteAddr = addr
|
||||||
|
return clientIPForBearer(r)
|
||||||
|
}
|
||||||
|
if a, b := key("[2001:db8:1:2::1]:1"), key("[2001:db8:1:2:ffff::9]:2"); a != b {
|
||||||
|
t.Fatalf("same /64 got different keys: %q vs %q", a, b)
|
||||||
|
}
|
||||||
|
if a, b := key("[2001:db8:1:2::1]:1"), key("[2001:db8:1:3::1]:1"); a == b {
|
||||||
|
t.Fatalf("different /64 shared key %q", a)
|
||||||
|
}
|
||||||
|
if got := key("192.0.2.7:80"); got != "192.0.2.7" {
|
||||||
|
t.Fatalf("ipv4 key = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -921,82 +921,6 @@ func TestMemoryTestOrchestrator_StopLeakDetection_CoverageBoost(t *testing.T) {
|
|||||||
// CHUNK MANAGER TESTS
|
// CHUNK MANAGER TESTS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
func TestChunkManager_GetSessionCount_CoverageBoost(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
cm := NewChunkManager(logger)
|
|
||||||
if cm == nil {
|
|
||||||
t.Fatal("Expected non-nil ChunkManager")
|
|
||||||
}
|
|
||||||
defer cm.Shutdown()
|
|
||||||
|
|
||||||
// Test GetSessionCount
|
|
||||||
count := cm.GetSessionCount()
|
|
||||||
if count != 0 {
|
|
||||||
t.Errorf("Expected 0 sessions, got %d", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChunkManager_GetMemoryStats_CoverageBoost(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
cm := NewChunkManager(logger)
|
|
||||||
if cm == nil {
|
|
||||||
t.Fatal("Expected non-nil ChunkManager")
|
|
||||||
}
|
|
||||||
defer cm.Shutdown()
|
|
||||||
|
|
||||||
// Test GetMemoryStats
|
|
||||||
stats := cm.GetMemoryStats()
|
|
||||||
if stats == nil {
|
|
||||||
t.Fatal("Expected non-nil stats")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify expected keys exist
|
|
||||||
if _, ok := stats["active_sessions"]; !ok {
|
|
||||||
t.Error("Expected active_sessions key in stats")
|
|
||||||
}
|
|
||||||
if _, ok := stats["max_sessions"]; !ok {
|
|
||||||
t.Error("Expected max_sessions key in stats")
|
|
||||||
}
|
|
||||||
if _, ok := stats["bytes_allocated"]; !ok {
|
|
||||||
t.Error("Expected bytes_allocated key in stats")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChunkManager_CanCreateSession_CoverageBoost(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
cm := NewChunkManager(logger)
|
|
||||||
if cm == nil {
|
|
||||||
t.Fatal("Expected non-nil ChunkManager")
|
|
||||||
}
|
|
||||||
defer cm.Shutdown()
|
|
||||||
|
|
||||||
// Test CanCreateSession - should be true initially
|
|
||||||
canCreate, err := cm.CanCreateSession()
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !canCreate {
|
|
||||||
t.Error("Expected CanCreateSession to return true when empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChunkManager_EmergencyCleanup_CoverageBoost(t *testing.T) {
|
|
||||||
logger := NewLogger("debug")
|
|
||||||
cm := NewChunkManager(logger)
|
|
||||||
if cm == nil {
|
|
||||||
t.Fatal("Expected non-nil ChunkManager")
|
|
||||||
}
|
|
||||||
defer cm.Shutdown()
|
|
||||||
|
|
||||||
// Test EmergencyCleanup - should not panic on empty session map
|
|
||||||
cm.EmergencyCleanup()
|
|
||||||
|
|
||||||
// Verify no sessions exist
|
|
||||||
if cm.GetSessionCount() != 0 {
|
|
||||||
t.Error("Expected 0 sessions after cleanup")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChunkManager_CleanupExpiredSessions_CoverageBoost(t *testing.T) {
|
func TestChunkManager_CleanupExpiredSessions_CoverageBoost(t *testing.T) {
|
||||||
logger := NewLogger("debug")
|
logger := NewLogger("debug")
|
||||||
cm := NewChunkManager(logger)
|
cm := NewChunkManager(logger)
|
||||||
@@ -1008,8 +932,7 @@ func TestChunkManager_CleanupExpiredSessions_CoverageBoost(t *testing.T) {
|
|||||||
// Test CleanupExpiredSessions - should not panic on empty session map
|
// Test CleanupExpiredSessions - should not panic on empty session map
|
||||||
cm.CleanupExpiredSessions()
|
cm.CleanupExpiredSessions()
|
||||||
|
|
||||||
// Verify no sessions exist
|
if len(cm.sessionMap) != 0 {
|
||||||
if cm.GetSessionCount() != 0 {
|
|
||||||
t.Error("Expected 0 sessions after cleanup")
|
t.Error("Expected 0 sessions after cleanup")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ownedHeaderOIDC(t *testing.T, names ...string) *TraefikOidc {
|
||||||
|
t.Helper()
|
||||||
|
m := map[string]*template.Template{}
|
||||||
|
for _, n := range names {
|
||||||
|
m[n] = template.Must(template.New(n).Option("missingkey=zero").Parse("{{.Claims.tenant}}"))
|
||||||
|
}
|
||||||
|
return &TraefikOidc{logger: NewLogger("error"), headerTemplates: m}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A templated header the middleware owns must never carry a client value:
|
||||||
|
// an optional claim that renders empty must leave the header absent.
|
||||||
|
func TestTemplatedHeaderDropsClientSpoof(t *testing.T) {
|
||||||
|
o := ownedHeaderOIDC(t, "X-Tenant")
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Set("X-Tenant", "admin-corp")
|
||||||
|
o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"sub": "u"}})
|
||||||
|
if got := req.Header.Get("X-Tenant"); got != "" {
|
||||||
|
t.Fatalf("client-supplied X-Tenant reached the backend: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplatedHeaderRenderedValueWins(t *testing.T) {
|
||||||
|
o := ownedHeaderOIDC(t, "X-Tenant")
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Set("X-Tenant", "evil")
|
||||||
|
o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"tenant": "acme"}})
|
||||||
|
if got := req.Header.Get("X-Tenant"); got != "acme" {
|
||||||
|
t.Fatalf("X-Tenant = %q, want acme", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity headers are injected before templates run; an empty render must
|
||||||
|
// not clobber them, and Authorization stays as received.
|
||||||
|
func TestTemplatedHeaderKeepsIdentityAndAuthorization(t *testing.T) {
|
||||||
|
o := ownedHeaderOIDC(t, "X-Forwarded-User", "Authorization")
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.Header.Set("X-Forwarded-User", "alice")
|
||||||
|
req.Header.Set("Authorization", "Bearer abc")
|
||||||
|
o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"sub": "u"}})
|
||||||
|
if got := req.Header.Get("X-Forwarded-User"); got != "alice" {
|
||||||
|
t.Fatalf("identity header clobbered: %q", got)
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("Authorization"); got != "Bearer abc" {
|
||||||
|
t.Fatalf("Authorization changed: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-3
@@ -13,6 +13,7 @@ import (
|
|||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -293,10 +294,37 @@ func extractClaims(tokenString string) (map[string]interface{}, error) {
|
|||||||
// TokenCache provides a specialized cache for JWT tokens and their parsed claims.
|
// TokenCache provides a specialized cache for JWT tokens and their parsed claims.
|
||||||
// It wraps the UniversalCache with token-specific operations.
|
// It wraps the UniversalCache with token-specific operations.
|
||||||
type TokenCache struct {
|
type TokenCache struct {
|
||||||
|
// ns scopes entries to one middleware instance (issuer|client|audience)
|
||||||
|
// so a verdict cached by one instance cannot satisfy another.
|
||||||
|
ns atomic.Value
|
||||||
// cache is the underlying universal cache implementation
|
// cache is the underlying universal cache implementation
|
||||||
cache *UniversalCache
|
cache *UniversalCache
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetNamespace sets the verification context that scopes this cache's keys.
|
||||||
|
func (tc *TokenCache) SetNamespace(ns string) {
|
||||||
|
if tc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tc.ns.Store(ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Namespace returns the scope set by SetNamespace, or "" when none is set.
|
||||||
|
func (tc *TokenCache) Namespace() string {
|
||||||
|
if tc == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ns, _ := tc.ns.Load().(string)
|
||||||
|
return ns
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TokenCache) key(token string) string {
|
||||||
|
if ns, _ := tc.ns.Load().(string); ns != "" {
|
||||||
|
return "t-" + ns + "|" + token
|
||||||
|
}
|
||||||
|
return "t-" + token
|
||||||
|
}
|
||||||
|
|
||||||
// NewTokenCache creates and initializes a new TokenCache.
|
// NewTokenCache creates and initializes a new TokenCache.
|
||||||
// It uses the global cache manager to ensure singleton behavior.
|
// It uses the global cache manager to ensure singleton behavior.
|
||||||
func NewTokenCache() *TokenCache {
|
func NewTokenCache() *TokenCache {
|
||||||
@@ -313,7 +341,7 @@ func NewTokenCache() *TokenCache {
|
|||||||
// - claims: Parsed claims from the token
|
// - claims: Parsed claims from the token
|
||||||
// - expiration: The duration for which the cache entry should be valid
|
// - expiration: The duration for which the cache entry should be valid
|
||||||
func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiration time.Duration) {
|
func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiration time.Duration) {
|
||||||
token = "t-" + token
|
token = tc.key(token)
|
||||||
_ = tc.cache.Set(token, claims, expiration) // Safe to ignore: cache failures are non-critical
|
_ = tc.cache.Set(token, claims, expiration) // Safe to ignore: cache failures are non-critical
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +353,7 @@ func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiratio
|
|||||||
// - map[string]interface{}: The cached claims if found
|
// - map[string]interface{}: The cached claims if found
|
||||||
// - A boolean indicating whether the token was found in the cache (true if found, false otherwise)
|
// - A boolean indicating whether the token was found in the cache (true if found, false otherwise)
|
||||||
func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
|
func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
|
||||||
token = "t-" + token
|
token = tc.key(token)
|
||||||
value, found := tc.cache.Get(token)
|
value, found := tc.cache.Get(token)
|
||||||
if !found {
|
if !found {
|
||||||
return nil, false
|
return nil, false
|
||||||
@@ -338,7 +366,7 @@ func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
|
|||||||
// Parameters:
|
// Parameters:
|
||||||
// - token: The raw token string to remove from the cache
|
// - token: The raw token string to remove from the cache
|
||||||
func (tc *TokenCache) Delete(token string) {
|
func (tc *TokenCache) Delete(token string) {
|
||||||
token = "t-" + token
|
token = tc.key(token)
|
||||||
tc.cache.Delete(token)
|
tc.cache.Delete(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateHostCanonicalForms(t *testing.T) {
|
||||||
|
o := &TraefikOidc{}
|
||||||
|
blocked := []string{
|
||||||
|
"localhost.", "LOCALHOST.", "metadata.google.internal.", "metadata.google.internal",
|
||||||
|
"169.254.169.254.", "fe80::1%eth0", "[fe80::1]", "::1",
|
||||||
|
}
|
||||||
|
for _, h := range blocked {
|
||||||
|
if err := o.validateHost(h); err == nil {
|
||||||
|
t.Errorf("validateHost(%q) = nil, want blocked", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allowed := []string{"idp.example.com", "idp.example.com.", "2606:4700::1111"}
|
||||||
|
for _, h := range allowed {
|
||||||
|
if err := o.validateHost(h); err != nil {
|
||||||
|
t.Errorf("validateHost(%q) = %v, want allowed", h, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isLoopbackHost("localhost.") {
|
||||||
|
t.Error("isLoopbackHost(localhost.) = false")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnhanceSessionSecurity_CaseVariantForwardedHostStaysHostScoped(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
xfh string
|
||||||
|
}{
|
||||||
|
{name: "exact-case parent", xfh: "corp.example.com"},
|
||||||
|
{name: "upper-case parent", xfh: "CORP.example.com"},
|
||||||
|
{name: "mixed-case parent", xfh: "Corp.Example.COM"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
sm := createTestSessionManager(t)
|
||||||
|
t.Cleanup(func() { _ = sm.Shutdown() })
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "https://app.corp.example.com/", nil)
|
||||||
|
req.Host = "app.corp.example.com"
|
||||||
|
req.Header.Set("X-Forwarded-Host", tc.xfh)
|
||||||
|
opts := sm.EnhanceSessionSecurity(nil, req)
|
||||||
|
if opts.Domain != "app.corp.example.com" {
|
||||||
|
t.Fatalf("Domain = %q, want host-scoped app.corp.example.com", opts.Domain)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("case-variant forwarded host equal to serving host is kept", func(t *testing.T) {
|
||||||
|
sm := createTestSessionManager(t)
|
||||||
|
t.Cleanup(func() { _ = sm.Shutdown() })
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "https://internal.svc/", nil)
|
||||||
|
req.Host = "internal.svc"
|
||||||
|
req.Header.Set("X-Forwarded-Host", "App.Example.com")
|
||||||
|
opts := sm.EnhanceSessionSecurity(nil, req)
|
||||||
|
if opts.Domain != "app.example.com" {
|
||||||
|
t.Fatalf("Domain = %q, want app.example.com", opts.Domain)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnforceIatAge_RejectsFutureIat(t *testing.T) {
|
||||||
|
const maxAge = 24 * time.Hour
|
||||||
|
now := time.Now()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
iat time.Time
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "now", iat: now},
|
||||||
|
{name: "within future skew", iat: now.Add(ClockSkewToleranceFuture / 2)},
|
||||||
|
{name: "one hour ahead", iat: now.Add(time.Hour), wantErr: true},
|
||||||
|
{name: "just beyond skew", iat: now.Add(ClockSkewToleranceFuture + 10*time.Second), wantErr: true},
|
||||||
|
{name: "older than max age", iat: now.Add(-48 * time.Hour), wantErr: true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := enforceIatAge(map[string]interface{}{"iat": float64(tc.iat.Unix())}, maxAge)
|
||||||
|
if (err != nil) != tc.wantErr {
|
||||||
|
t.Fatalf("enforceIatAge err = %v, wantErr %v", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJWKCache_CooldownServesParsedEntryWithoutReparse(t *testing.T) {
|
||||||
|
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("keygen: %v", err)
|
||||||
|
}
|
||||||
|
c := NewJWKCache()
|
||||||
|
url := fmt.Sprintf("https://jwks.invalid/%d", time.Now().UnixNano())
|
||||||
|
// The raw set is empty, so only the pre-parsed entry can satisfy the lookup.
|
||||||
|
_ = c.cache.SetLocal(url, &JWKSet{}, time.Hour)
|
||||||
|
_ = c.cache.SetLocal(url+parsedKeysSuffix, &parsedJWKS{keys: map[string]crypto.PublicKey{"k1": &priv.PublicKey}}, time.Hour)
|
||||||
|
c.lastSignatureRefresh[url] = time.Now()
|
||||||
|
|
||||||
|
got, err := c.getPublicKeyFresh(context.Background(), url, "k1", http.DefaultClient)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getPublicKeyFresh: %v", err)
|
||||||
|
}
|
||||||
|
if got != crypto.PublicKey(&priv.PublicKey) {
|
||||||
|
t.Fatal("cooldown branch did not serve the cached parsed key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrontchannelLogout_FloodCannotEvictSignedMarkers(t *testing.T) {
|
||||||
|
cache := &CacheInterfaceWrapper{cache: NewUniversalCache(UniversalCacheConfig{
|
||||||
|
Type: CacheTypeSession,
|
||||||
|
MaxSize: 5000,
|
||||||
|
DefaultTTL: sessionInvalidationTTL,
|
||||||
|
Logger: NewLogger("error"),
|
||||||
|
SkipAutoCleanup: true,
|
||||||
|
MonotonicMarkers: true,
|
||||||
|
})}
|
||||||
|
oidc := &TraefikOidc{
|
||||||
|
logger: NewLogger("error"),
|
||||||
|
enableFrontchannelLogout: true,
|
||||||
|
sessionInvalidationCache: cache,
|
||||||
|
issuerURL: "https://idp.example.com",
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = cache.cache.Close() })
|
||||||
|
|
||||||
|
if err := oidc.invalidateSession("", "real-subject"); err != nil {
|
||||||
|
t.Fatalf("invalidateSession: %v", err)
|
||||||
|
}
|
||||||
|
if err := oidc.checkAndMarkLogoutJTIProcessed("real-jti", time.Now().Unix()); err != nil {
|
||||||
|
t.Fatalf("mark jti: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 6000; i++ {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/fc?iss=https://idp.example.com&sid=flood-%d", i), nil)
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
oidc.handleFrontchannelLogout(rw, req)
|
||||||
|
if rw.Code != http.StatusOK {
|
||||||
|
t.Fatalf("flood request %d: status %d", i, rw.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := cache.Get(oidc.buildSessionInvalidationKey("sub", "real-subject")); !ok {
|
||||||
|
t.Error("backchannel sub marker evicted by front-channel flood")
|
||||||
|
}
|
||||||
|
if err := oidc.checkAndMarkLogoutJTIProcessed("real-jti", time.Now().Unix()); err == nil {
|
||||||
|
t.Error("jti replay marker evicted by front-channel flood: replay accepted")
|
||||||
|
}
|
||||||
|
held := 0
|
||||||
|
for i := 0; i < 6000; i++ {
|
||||||
|
if _, ok := cache.Get(oidc.buildSessionInvalidationKey("sid", fmt.Sprintf("flood-%d", i))); ok {
|
||||||
|
held++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if held > maxFrontchannelMarkers {
|
||||||
|
t.Errorf("front-channel markers held = %d, want <= %d", held, maxFrontchannelMarkers)
|
||||||
|
}
|
||||||
|
if _, ok := cache.Get(oidc.buildSessionInvalidationKey("sid", "flood-5999")); !ok {
|
||||||
|
t.Error("most recent front-channel marker must remain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkerQuota_Admit(t *testing.T) {
|
||||||
|
var q markerQuota
|
||||||
|
if got := q.admit("a", 2); got != "" {
|
||||||
|
t.Errorf("admit a = %q, want none", got)
|
||||||
|
}
|
||||||
|
if got := q.admit("a", 2); got != "" {
|
||||||
|
t.Errorf("re-admit a = %q, want none", got)
|
||||||
|
}
|
||||||
|
if got := q.admit("b", 2); got != "" {
|
||||||
|
t.Errorf("admit b = %q, want none", got)
|
||||||
|
}
|
||||||
|
if got := q.admit("c", 2); got != "a" {
|
||||||
|
t.Errorf("admit c evicted %q, want a", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeHTTP_InitWaitQueueFullFailsFast(t *testing.T) {
|
||||||
|
oidc := &TraefikOidc{
|
||||||
|
logger: NewLogger("error"),
|
||||||
|
initComplete: make(chan struct{}),
|
||||||
|
sessionManager: createTestSessionManager(t),
|
||||||
|
firstRequestStarted: 1,
|
||||||
|
metadataRefreshStartedAtomic: 1,
|
||||||
|
}
|
||||||
|
oidc.initWaiters = maxInitWaiters
|
||||||
|
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
start := time.Now()
|
||||||
|
oidc.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/protected", nil))
|
||||||
|
|
||||||
|
if rw.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("status = %d, want 503", rw.Code)
|
||||||
|
}
|
||||||
|
if rw.Header().Get("Retry-After") == "" {
|
||||||
|
t.Error("missing Retry-After header")
|
||||||
|
}
|
||||||
|
if time.Since(start) > 5*time.Second {
|
||||||
|
t.Errorf("rejected after %v, want immediate", time.Since(start))
|
||||||
|
}
|
||||||
|
if oidc.initWaiters != maxInitWaiters {
|
||||||
|
t.Errorf("initWaiters = %d, want unchanged %d", oidc.initWaiters, maxInitWaiters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeHTTP_InitWaiterReleasedOnCancel(t *testing.T) {
|
||||||
|
oidc := &TraefikOidc{
|
||||||
|
logger: NewLogger("error"),
|
||||||
|
initComplete: make(chan struct{}),
|
||||||
|
sessionManager: createTestSessionManager(t),
|
||||||
|
firstRequestStarted: 1,
|
||||||
|
metadataRefreshStartedAtomic: 1,
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil).WithContext(ctx)
|
||||||
|
oidc.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
if oidc.initWaiters != 0 {
|
||||||
|
t.Fatalf("initWaiters = %d after canceled wait, want 0", oidc.initWaiters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupOldCookies_CapsDeletionHeaders(t *testing.T) {
|
||||||
|
sm, err := NewSessionManager("test-encryption-key-32-bytes-long!!", false, "cookie.example.com", "testpref", 0, newNoOpLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session manager: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = sm.Shutdown() })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Host = "a.b.example.com"
|
||||||
|
for i := 0; i < 180; i++ {
|
||||||
|
req.AddCookie(&http.Cookie{Name: fmt.Sprintf("testpref_%d", i), Value: "x"})
|
||||||
|
}
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
sm.CleanupOldCookies(rw, req)
|
||||||
|
|
||||||
|
got := len(rw.Result().Cookies())
|
||||||
|
if got == 0 {
|
||||||
|
t.Fatal("expected deletion cookies for the first wave")
|
||||||
|
}
|
||||||
|
if limit := maxCleanupCookies * 4; got > limit {
|
||||||
|
t.Fatalf("deletion Set-Cookie headers = %d, want <= %d", got, limit)
|
||||||
|
}
|
||||||
|
for _, c := range rw.Result().Cookies() {
|
||||||
|
if !strings.HasPrefix(c.Name, "testpref_") {
|
||||||
|
t.Errorf("unexpected deletion cookie %q", c.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+20
-18
@@ -120,15 +120,8 @@ func (r *RedisBackend) Set(ctx context.Context, key string, value []byte, ttl ti
|
|||||||
|
|
||||||
// Use PSETEX for millisecond precision, SETEX for second precision
|
// Use PSETEX for millisecond precision, SETEX for second precision
|
||||||
if ttl > 0 {
|
if ttl > 0 {
|
||||||
ttlMillis := ttl.Milliseconds()
|
cmd, arg := ttlCommand(ttl)
|
||||||
if ttlMillis < 1000 {
|
_, err = conn.Do(cmd, prefixedKey, arg, string(value))
|
||||||
// Use PSETEX for sub-second TTLs (millisecond precision)
|
|
||||||
_, err = conn.Do("PSETEX", prefixedKey, fmt.Sprintf("%d", ttlMillis), string(value))
|
|
||||||
} else {
|
|
||||||
// Use SETEX for larger TTLs (second precision)
|
|
||||||
ttlSeconds := int(ttl.Seconds())
|
|
||||||
_, err = conn.Do("SETEX", prefixedKey, fmt.Sprintf("%d", ttlSeconds), string(value))
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
_, err = conn.Do("SET", prefixedKey, string(value))
|
_, err = conn.Do("SET", prefixedKey, string(value))
|
||||||
}
|
}
|
||||||
@@ -624,6 +617,19 @@ func isRetryableError(err error) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ttlCommand picks SETEX for whole seconds and PSETEX otherwise. The expiry is
|
||||||
|
// rounded up to a millisecond and is never zero, so a key never dies early.
|
||||||
|
func ttlCommand(ttl time.Duration) (cmd, arg string) {
|
||||||
|
ms := (ttl + time.Millisecond - 1).Milliseconds()
|
||||||
|
if ms < 1 {
|
||||||
|
ms = 1
|
||||||
|
}
|
||||||
|
if ms >= 1000 && ms%1000 == 0 {
|
||||||
|
return "SETEX", fmt.Sprintf("%d", ms/1000)
|
||||||
|
}
|
||||||
|
return "PSETEX", fmt.Sprintf("%d", ms)
|
||||||
|
}
|
||||||
|
|
||||||
// SetMany stores multiple values in Redis using pipelining for efficiency
|
// SetMany stores multiple values in Redis using pipelining for efficiency
|
||||||
// This reduces N round-trips to a single round-trip
|
// This reduces N round-trips to a single round-trip
|
||||||
func (r *RedisBackend) SetMany(ctx context.Context, items map[string][]byte, ttl time.Duration) error {
|
func (r *RedisBackend) SetMany(ctx context.Context, items map[string][]byte, ttl time.Duration) error {
|
||||||
@@ -658,20 +664,16 @@ func (r *RedisBackend) SetMany(ctx context.Context, items map[string][]byte, ttl
|
|||||||
pipeline := conn.NewPipeline()
|
pipeline := conn.NewPipeline()
|
||||||
|
|
||||||
// Queue all SET commands
|
// Queue all SET commands
|
||||||
ttlSeconds := int(ttl.Seconds())
|
var ttlCmd, ttlArg string
|
||||||
ttlMillis := ttl.Milliseconds()
|
if ttl > 0 {
|
||||||
|
ttlCmd, ttlArg = ttlCommand(ttl)
|
||||||
|
}
|
||||||
|
|
||||||
for key, value := range items {
|
for key, value := range items {
|
||||||
prefixedKey := r.prefixKey(key)
|
prefixedKey := r.prefixKey(key)
|
||||||
|
|
||||||
if ttl > 0 {
|
if ttl > 0 {
|
||||||
if ttlMillis < 1000 {
|
pipeline.Queue(ttlCmd, prefixedKey, ttlArg, string(value))
|
||||||
// Use PSETEX for sub-second TTLs
|
|
||||||
pipeline.Queue("PSETEX", prefixedKey, fmt.Sprintf("%d", ttlMillis), string(value))
|
|
||||||
} else {
|
|
||||||
// Use SETEX for larger TTLs
|
|
||||||
pipeline.Queue("SETEX", prefixedKey, fmt.Sprintf("%d", ttlSeconds), string(value))
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
pipeline.Queue("SET", prefixedKey, string(value))
|
pipeline.Queue("SET", prefixedKey, string(value))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package backends
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRedisSetTTLNeverZeroOrEarly(t *testing.T) {
|
||||||
|
mr := NewMiniredisServer(t)
|
||||||
|
b, err := NewRedisBackend(DefaultRedisConfig(mr.GetAddr()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer b.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("sub-millisecond ttl is accepted", func(t *testing.T) {
|
||||||
|
require.NoError(t, b.Set(ctx, "sub", []byte("v"), 500*time.Microsecond))
|
||||||
|
})
|
||||||
|
t.Run("sub-millisecond ttl accepted by SetMany", func(t *testing.T) {
|
||||||
|
require.NoError(t, b.SetMany(ctx, map[string][]byte{"sub-many": []byte("v")}, 500*time.Microsecond))
|
||||||
|
})
|
||||||
|
t.Run("fractional seconds are not rounded down", func(t *testing.T) {
|
||||||
|
require.NoError(t, b.Set(ctx, "frac", []byte("v"), 1999*time.Millisecond))
|
||||||
|
_, ttl, ok, err := b.Get(ctx, "frac")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Greater(t, ttl, time.Second, "1999ms ttl must not be stored as 1s")
|
||||||
|
})
|
||||||
|
t.Run("fractional seconds SetMany", func(t *testing.T) {
|
||||||
|
require.NoError(t, b.SetMany(ctx, map[string][]byte{"frac-many": []byte("v")}, 1999*time.Millisecond))
|
||||||
|
_, ttl, ok, err := b.Get(ctx, "frac-many")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Greater(t, ttl, time.Second)
|
||||||
|
})
|
||||||
|
t.Run("whole seconds still work", func(t *testing.T) {
|
||||||
|
require.NoError(t, b.Set(ctx, "whole", []byte("v"), 5*time.Second))
|
||||||
|
_, ttl, ok, err := b.Get(ctx, "whole")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Greater(t, ttl, 3*time.Second)
|
||||||
|
})
|
||||||
|
}
|
||||||
+6
-3
@@ -81,6 +81,7 @@ type CircuitBreaker struct {
|
|||||||
stateMu sync.RWMutex
|
stateMu sync.RWMutex
|
||||||
timeMu sync.RWMutex
|
timeMu sync.RWMutex
|
||||||
halfOpenRequests atomic.Int32
|
halfOpenRequests atomic.Int32
|
||||||
|
halfOpenSuccesses atomic.Int32
|
||||||
consecutiveFailures atomic.Int32
|
consecutiveFailures atomic.Int32
|
||||||
state atomic.Int32
|
state atomic.Int32
|
||||||
}
|
}
|
||||||
@@ -170,12 +171,12 @@ func (cb *CircuitBreaker) RecordSuccess() {
|
|||||||
|
|
||||||
case StateHalfOpen:
|
case StateHalfOpen:
|
||||||
// If we've had enough successful requests, close the circuit
|
// If we've had enough successful requests, close the circuit
|
||||||
successfulRequests := cb.halfOpenRequests.Load()
|
successfulRequests := cb.halfOpenSuccesses.Add(1)
|
||||||
// #nosec G115 -- HalfOpenMaxRequests is a small config value that fits in int32
|
if int64(successfulRequests) >= int64(cb.config.HalfOpenMaxRequests) {
|
||||||
if successfulRequests >= int32(cb.config.HalfOpenMaxRequests) {
|
|
||||||
cb.setState(StateClosed)
|
cb.setState(StateClosed)
|
||||||
cb.consecutiveFailures.Store(0)
|
cb.consecutiveFailures.Store(0)
|
||||||
cb.halfOpenRequests.Store(0)
|
cb.halfOpenRequests.Store(0)
|
||||||
|
cb.halfOpenSuccesses.Store(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,6 +217,7 @@ func (cb *CircuitBreaker) RecordFailure() {
|
|||||||
func (cb *CircuitBreaker) openCircuit() {
|
func (cb *CircuitBreaker) openCircuit() {
|
||||||
cb.setState(StateOpen)
|
cb.setState(StateOpen)
|
||||||
cb.halfOpenRequests.Store(0)
|
cb.halfOpenRequests.Store(0)
|
||||||
|
cb.halfOpenSuccesses.Store(0)
|
||||||
|
|
||||||
cb.timeMu.Lock()
|
cb.timeMu.Lock()
|
||||||
cb.nextRetryTime = time.Now().Add(cb.config.Timeout)
|
cb.nextRetryTime = time.Now().Add(cb.config.Timeout)
|
||||||
@@ -251,6 +253,7 @@ func (cb *CircuitBreaker) Reset() {
|
|||||||
cb.totalRequests.Store(0)
|
cb.totalRequests.Store(0)
|
||||||
cb.totalFailures.Store(0)
|
cb.totalFailures.Store(0)
|
||||||
cb.halfOpenRequests.Store(0)
|
cb.halfOpenRequests.Store(0)
|
||||||
|
cb.halfOpenSuccesses.Store(0)
|
||||||
cb.rejectedRequests.Store(0)
|
cb.rejectedRequests.Store(0)
|
||||||
cb.stateTransitions.Store(0)
|
cb.stateTransitions.Store(0)
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -168,11 +168,12 @@ func (h *HealthCheckBackend) IsHealthy() bool {
|
|||||||
func (h *HealthCheckBackend) recordResult(success bool) {
|
func (h *HealthCheckBackend) recordResult(success bool) {
|
||||||
// #nosec G115 -- threshold config values are small integers that fit in int32
|
// #nosec G115 -- threshold config values are small integers that fit in int32
|
||||||
if success {
|
if success {
|
||||||
fails := h.consecutiveFails.Swap(0)
|
h.consecutiveFails.Store(0)
|
||||||
oks := h.consecutiveOK.Add(1)
|
oks := h.consecutiveOK.Add(1)
|
||||||
|
|
||||||
// Check if we should transition to healthy
|
// Promote on a success streak. Keyed on the status, not on the fail
|
||||||
if fails > 0 && oks >= int32(h.config.HealthyThreshold) {
|
// counter, which the first success of the streak already zeroed.
|
||||||
|
if HealthStatus(h.status.Load()) != HealthHealthy && oks >= int32(h.config.HealthyThreshold) {
|
||||||
oldStatus := HealthStatus(h.status.Swap(int32(HealthHealthy)))
|
oldStatus := HealthStatus(h.status.Swap(int32(HealthHealthy)))
|
||||||
if oldStatus != HealthHealthy && h.config.OnStatusChange != nil {
|
if oldStatus != HealthHealthy && h.config.OnStatusChange != nil {
|
||||||
h.config.OnStatusChange(oldStatus, HealthHealthy)
|
h.config.OnStatusChange(oldStatus, HealthHealthy)
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package resilience
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// With HealthyThreshold >= 2 an Unhealthy backend must recover once it
|
||||||
|
// answers HealthyThreshold times in a row.
|
||||||
|
func TestHealthCheckRecoversWithThresholdTwo(t *testing.T) {
|
||||||
|
h := &HealthCheckBackend{config: &HealthCheckConfig{HealthyThreshold: 2, UnhealthyThreshold: 3}}
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
h.recordResult(false)
|
||||||
|
}
|
||||||
|
if HealthStatus(h.status.Load()) != HealthUnhealthy {
|
||||||
|
t.Fatalf("precondition: status=%v, want unhealthy", HealthStatus(h.status.Load()))
|
||||||
|
}
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
h.recordResult(true)
|
||||||
|
}
|
||||||
|
if got := HealthStatus(h.status.Load()); got != HealthHealthy {
|
||||||
|
t.Fatalf("status after 5 successes = %v, want healthy", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Half-open closes after HalfOpenMaxRequests successes, not admissions.
|
||||||
|
func TestHalfOpenClosesOnSuccessesNotAdmissions(t *testing.T) {
|
||||||
|
cb := NewCircuitBreaker(&CircuitBreakerConfig{
|
||||||
|
MaxFailures: 1, Timeout: time.Millisecond, HalfOpenMaxRequests: 3, ResetTimeout: time.Second,
|
||||||
|
})
|
||||||
|
cb.RecordFailure()
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if !cb.AllowRequest() {
|
||||||
|
t.Fatalf("probe %d rejected", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cb.RecordSuccess()
|
||||||
|
if cb.GetState() != StateHalfOpen {
|
||||||
|
t.Fatalf("closed after 1 of 3 probe successes; state=%v", cb.GetState())
|
||||||
|
}
|
||||||
|
cb.RecordFailure()
|
||||||
|
if cb.GetState() != StateOpen {
|
||||||
|
t.Fatalf("a failing probe must reopen; state=%v", cb.GetState())
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
cb.AllowRequest()
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
cb.RecordSuccess()
|
||||||
|
}
|
||||||
|
if cb.GetState() != StateClosed {
|
||||||
|
t.Fatalf("state after 3 successes = %v, want closed", cb.GetState())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,8 +93,16 @@ func (cb *CircuitBreaker) ExecuteWithContext(ctx context.Context, fn func() erro
|
|||||||
return fmt.Errorf("circuit breaker is open")
|
return fmt.Errorf("circuit breaker is open")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute the function
|
// A panicking fn must still settle the breaker, otherwise a half-open
|
||||||
|
// probe slot leaks until the next open->half-open transition.
|
||||||
|
panicked := true
|
||||||
|
defer func() {
|
||||||
|
if panicked {
|
||||||
|
cb.recordFailure()
|
||||||
|
}
|
||||||
|
}()
|
||||||
err := fn()
|
err := fn()
|
||||||
|
panicked = false
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cb.recordFailure()
|
cb.recordFailure()
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCircuitBreaker_PanickingProbeReleasesHalfOpenSlot(t *testing.T) {
|
||||||
|
cb := NewCircuitBreaker(CircuitBreakerConfig{
|
||||||
|
FailureThreshold: 1,
|
||||||
|
SuccessThreshold: 1,
|
||||||
|
Timeout: 20 * time.Millisecond,
|
||||||
|
MaxRequests: 1,
|
||||||
|
}, &mockLogger{})
|
||||||
|
|
||||||
|
_ = cb.ExecuteWithContext(context.Background(), func() error { return errors.New("fail") })
|
||||||
|
time.Sleep(40 * time.Millisecond)
|
||||||
|
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Error("panic must still propagate to the caller")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_ = cb.ExecuteWithContext(context.Background(), func() error { panic("boom") })
|
||||||
|
}()
|
||||||
|
|
||||||
|
if cb.GetState() != CircuitBreakerOpen {
|
||||||
|
t.Fatalf("state after panicking probe = %s, want open", cb.GetState())
|
||||||
|
}
|
||||||
|
time.Sleep(40 * time.Millisecond)
|
||||||
|
if err := cb.ExecuteWithContext(context.Background(), func() error { return nil }); err != nil {
|
||||||
|
t.Fatalf("probe after next timeout rejected: %v", err)
|
||||||
|
}
|
||||||
|
if cb.GetState() != CircuitBreakerClosed {
|
||||||
|
t.Fatalf("state = %s, want closed", cb.GetState())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,7 +142,7 @@ func DetermineScheme(req *http.Request, forceHTTPS bool) string {
|
|||||||
// "X-Forwarded-Proto: ftp") would otherwise be emitted verbatim into
|
// "X-Forwarded-Proto: ftp") would otherwise be emitted verbatim into
|
||||||
// redirect_uri as a broken or weakened scheme. Invalid values fall
|
// redirect_uri as a broken or weakened scheme. Invalid values fall
|
||||||
// through to the TLS check (R103).
|
// through to the TLS check (R103).
|
||||||
if scheme := req.Header.Get("X-Forwarded-Proto"); scheme == "https" || scheme == "http" {
|
if scheme := strings.ToLower(req.Header.Get("X-Forwarded-Proto")); scheme == "https" || scheme == "http" {
|
||||||
return scheme
|
return scheme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -389,6 +389,13 @@ func (c *JWKCache) getPublicKeyFresh(ctx context.Context, jwksURL, kid string, h
|
|||||||
|
|
||||||
if withinCooldown {
|
if withinCooldown {
|
||||||
// Serve the cached keyset; do not fetch upstream again.
|
// Serve the cached keyset; do not fetch upstream again.
|
||||||
|
if v, found := c.cache.GetLocal(jwksURL + parsedKeysSuffix); found {
|
||||||
|
if pj, ok := v.(*parsedJWKS); ok {
|
||||||
|
if k, ok := pj.keys[kid]; ok {
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if cached, found := c.cache.GetLocal(jwksURL); found {
|
if cached, found := c.cache.GetLocal(jwksURL); found {
|
||||||
if jwks, ok := cached.(*JWKSet); ok {
|
if jwks, ok := cached.(*JWKSet); ok {
|
||||||
if k, ok := buildParsedJWKS(jwks).keys[kid]; ok {
|
if k, ok := buildParsedJWKS(jwks).keys[kid]; ok {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -194,11 +196,16 @@ func (j *JWT) Verify(issuerURL, expectedAudience string) error {
|
|||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - An error if the claim type is invalid or the expected audience is not present
|
// - An error if the claim type is invalid or the expected audience is not present
|
||||||
|
//
|
||||||
|
// errInvalidAudience marks a token whose aud does not match; callers match it
|
||||||
|
// with errors.Is, never by message text.
|
||||||
|
var errInvalidAudience = errors.New("invalid audience")
|
||||||
|
|
||||||
func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
|
func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
|
||||||
switch aud := tokenAudience.(type) {
|
switch aud := tokenAudience.(type) {
|
||||||
case string:
|
case string:
|
||||||
if aud != expectedAudience {
|
if aud != expectedAudience {
|
||||||
return fmt.Errorf("invalid audience")
|
return errInvalidAudience
|
||||||
}
|
}
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
found := false
|
found := false
|
||||||
@@ -209,7 +216,7 @@ func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
return fmt.Errorf("invalid audience")
|
return errInvalidAudience
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid 'aud' claim type")
|
return fmt.Errorf("invalid 'aud' claim type")
|
||||||
@@ -231,6 +238,9 @@ func verifyIssuer(tokenIssuer, expectedIssuer string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxClaimUnix is 9999-12-31T23:59:59Z, the largest time claim accepted.
|
||||||
|
const maxClaimUnix = 253402300799
|
||||||
|
|
||||||
// verifyTimeConstraint validates time-based JWT claims with clock skew tolerance.
|
// verifyTimeConstraint validates time-based JWT claims with clock skew tolerance.
|
||||||
// It handles both future constraints (exp) and past constraints (iat, nbf).
|
// It handles both future constraints (exp) and past constraints (iat, nbf).
|
||||||
// Parameters:
|
// Parameters:
|
||||||
@@ -241,6 +251,10 @@ func verifyIssuer(tokenIssuer, expectedIssuer string) error {
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - An error describing the failure (e.g., "token has expired", "token used before issued")
|
// - An error describing the failure (e.g., "token has expired", "token used before issued")
|
||||||
func verifyTimeConstraint(unixTime float64, claimName string, future bool) error {
|
func verifyTimeConstraint(unixTime float64, claimName string, future bool) error {
|
||||||
|
// int64(float64) wraps out of range, which put a far-future nbf/iat in the past.
|
||||||
|
if math.IsNaN(unixTime) || math.IsInf(unixTime, 0) || math.Abs(unixTime) > maxClaimUnix {
|
||||||
|
return fmt.Errorf("token %s claim out of range", claimName)
|
||||||
|
}
|
||||||
claimTime := time.Unix(int64(unixTime), 0)
|
claimTime := time.Unix(int64(unixTime), 0)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
@@ -314,6 +328,9 @@ func verifyTimeClaims(claims map[string]interface{}) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ecdsaAlgBits maps an ES* algorithm to its curve size (RFC 7518 3.4).
|
||||||
|
var ecdsaAlgBits = map[string]int{"ES256": 256, "ES384": 384, "ES512": 521}
|
||||||
|
|
||||||
// verifySignatureWithKey verifies a JWT signature using an already-parsed
|
// verifySignatureWithKey verifies a JWT signature using an already-parsed
|
||||||
// public key, skipping the PEM-encode/decode round trip. This is the hot
|
// public key, skipping the PEM-encode/decode round trip. This is the hot
|
||||||
// path used by VerifyJWTSignatureAndClaims and verifyLogoutTokenSignature.
|
// path used by VerifyJWTSignatureAndClaims and verifyLogoutTokenSignature.
|
||||||
@@ -361,7 +378,13 @@ func verifySignatureWithKey(tokenString string, pubKey crypto.PublicKey, alg str
|
|||||||
if strings.HasPrefix(alg, "ES") {
|
if strings.HasPrefix(alg, "ES") {
|
||||||
var r, s big.Int
|
var r, s big.Int
|
||||||
sigLen := len(signature)
|
sigLen := len(signature)
|
||||||
if sigLen%2 != 0 {
|
// RFC 7518 3.4: the curve is fixed by the algorithm and the
|
||||||
|
// signature is R||S at exactly the curve's octet length.
|
||||||
|
bits := ecdsaAlgBits[alg]
|
||||||
|
if bits == 0 || pubKey.Curve.Params().BitSize != bits {
|
||||||
|
return fmt.Errorf("ECDSA key curve does not match algorithm %s", alg)
|
||||||
|
}
|
||||||
|
if sigLen != 2*((bits+7)/8) {
|
||||||
return fmt.Errorf("invalid ECDSA signature length")
|
return fmt.Errorf("invalid ECDSA signature length")
|
||||||
}
|
}
|
||||||
r.SetBytes(signature[:sigLen/2])
|
r.SetBytes(signature[:sigLen/2])
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base64"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func signES(t *testing.T, key *ecdsa.PrivateKey, signed string, h256 bool, size int) (sigRS []byte) {
|
||||||
|
t.Helper()
|
||||||
|
var digest []byte
|
||||||
|
if h256 {
|
||||||
|
d := sha256.Sum256([]byte(signed))
|
||||||
|
digest = d[:]
|
||||||
|
} else {
|
||||||
|
d := sha512.Sum384([]byte(signed))
|
||||||
|
digest = d[:]
|
||||||
|
}
|
||||||
|
r, s, err := ecdsa.Sign(rand.Reader, key, digest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := make([]byte, 2*size)
|
||||||
|
r.FillBytes(out[:size])
|
||||||
|
s.FillBytes(out[size:])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestECDSAVerifyIsStrict(t *testing.T) {
|
||||||
|
const signed = "eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJ1In0"
|
||||||
|
enc := base64.RawURLEncoding.EncodeToString
|
||||||
|
p256, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
p384, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||||
|
|
||||||
|
if err := verifySignatureWithKey(signed+"."+enc(signES(t, p256, signed, true, 32)), &p256.PublicKey, "ES256"); err != nil {
|
||||||
|
t.Fatalf("valid ES256 rejected: %v", err)
|
||||||
|
}
|
||||||
|
if err := verifySignatureWithKey(signed+"."+enc(signES(t, p384, signed, false, 48)), &p384.PublicKey, "ES384"); err != nil {
|
||||||
|
t.Fatalf("valid ES384 rejected: %v", err)
|
||||||
|
}
|
||||||
|
// ES256 token verified against a P-384 key.
|
||||||
|
if err := verifySignatureWithKey(signed+"."+enc(signES(t, p384, signed, true, 48)), &p384.PublicKey, "ES256"); err == nil {
|
||||||
|
t.Error("ES256 accepted with a P-384 key")
|
||||||
|
}
|
||||||
|
// Zero-padded halves: 66 bytes for ES256.
|
||||||
|
if err := verifySignatureWithKey(signed+"."+enc(signES(t, p256, signed, true, 33)), &p256.PublicKey, "ES256"); err == nil {
|
||||||
|
t.Error("ES256 accepted a 66-byte signature")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,8 +51,43 @@ const (
|
|||||||
// sessionInvalidationTTL is how long to remember invalidated sessions
|
// sessionInvalidationTTL is how long to remember invalidated sessions
|
||||||
// Should be at least as long as your session max age
|
// Should be at least as long as your session max age
|
||||||
sessionInvalidationTTL = 25 * time.Hour
|
sessionInvalidationTTL = 25 * time.Hour
|
||||||
|
|
||||||
|
// maxFrontchannelMarkers caps the sid markers the unauthenticated
|
||||||
|
// front-channel endpoint may keep in the shared invalidation cache, so
|
||||||
|
// a flood cannot LRU-evict signed backchannel and jti replay markers.
|
||||||
|
maxFrontchannelMarkers = 1000
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// markerQuota is a FIFO of cache keys written on behalf of one untrusted
|
||||||
|
// source. The zero value is ready to use.
|
||||||
|
type markerQuota struct {
|
||||||
|
set map[string]struct{}
|
||||||
|
keys []string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// admit records key and returns the oldest key to drop once more than limit
|
||||||
|
// keys are held, or "" when none must go. Re-admitting a held key is a no-op.
|
||||||
|
func (q *markerQuota) admit(key string, limit int) string {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
if q.set == nil {
|
||||||
|
q.set = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
if _, ok := q.set[key]; ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
q.set[key] = struct{}{}
|
||||||
|
q.keys = append(q.keys, key)
|
||||||
|
if len(q.keys) <= limit {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
oldest := q.keys[0]
|
||||||
|
q.keys = q.keys[1:]
|
||||||
|
delete(q.set, oldest)
|
||||||
|
return oldest
|
||||||
|
}
|
||||||
|
|
||||||
// LogoutTokenClaims represents the claims in an OIDC logout token
|
// LogoutTokenClaims represents the claims in an OIDC logout token
|
||||||
// as defined in OpenID Connect Back-Channel Logout 1.0
|
// as defined in OpenID Connect Back-Channel Logout 1.0
|
||||||
type LogoutTokenClaims struct {
|
type LogoutTokenClaims struct {
|
||||||
@@ -191,6 +226,10 @@ func (t *TraefikOidc) handleFrontchannelLogout(rw http.ResponseWriter, req *http
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if evicted := t.frontchannelMarkers.admit(t.buildSessionInvalidationKey("sid", sid), maxFrontchannelMarkers); evicted != "" {
|
||||||
|
t.sessionInvalidationCache.Delete(evicted)
|
||||||
|
}
|
||||||
|
|
||||||
t.logger.Infof("Front-channel logout: successfully invalidated session (sid=%s)", sid)
|
t.logger.Infof("Front-channel logout: successfully invalidated session (sid=%s)", sid)
|
||||||
|
|
||||||
// Return a minimal HTML response that's suitable for iframe loading
|
// Return a minimal HTML response that's suitable for iframe loading
|
||||||
@@ -578,6 +617,11 @@ func (t *TraefikOidc) isSessionInvalidated(sid, sub string, sessionCreatedAt tim
|
|||||||
|
|
||||||
// buildSessionInvalidationKey creates a cache key for session invalidation
|
// buildSessionInvalidationKey creates a cache key for session invalidation
|
||||||
func (t *TraefikOidc) buildSessionInvalidationKey(keyType, value string) string {
|
func (t *TraefikOidc) buildSessionInvalidationKey(keyType, value string) string {
|
||||||
|
// sub, sid and jti are unique only within an issuer; the cache is shared by
|
||||||
|
// every instance, so scope the key to this instance's provider and client.
|
||||||
|
if ns := t.tokenCache.Namespace(); ns != "" {
|
||||||
|
return fmt.Sprintf("session_invalidation:%s|%s:%s", ns, keyType, value)
|
||||||
|
}
|
||||||
return fmt.Sprintf("session_invalidation:%s:%s", keyType, value)
|
return fmt.Sprintf("session_invalidation:%s:%s", keyType, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,6 +644,22 @@ func (t *TraefikOidc) sessionCreatedAtForInvalidation(idToken string, session *S
|
|||||||
return time.Time{} // zero: treated as invalidated -> safe re-auth
|
return time.Time{} // zero: treated as invalidated -> safe re-auth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// invalidationInputs returns sid, sub and the creation time for the IdP-logout
|
||||||
|
// check from one parseJWT call, with the same results as extractSessionInfo
|
||||||
|
// and sessionCreatedAtForInvalidation called separately.
|
||||||
|
func (t *TraefikOidc) invalidationInputs(idToken string, session *SessionData) (sid, sub string, createdAt time.Time) {
|
||||||
|
jwt, err := parseJWT(idToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", t.sessionCreatedAtForInvalidation("", session)
|
||||||
|
}
|
||||||
|
sid, _ = jwt.Claims["sid"].(string)
|
||||||
|
sub, _ = jwt.Claims["sub"].(string)
|
||||||
|
if iat, ok := jwt.Claims["iat"].(float64); ok {
|
||||||
|
return sid, sub, time.Unix(int64(iat), 0)
|
||||||
|
}
|
||||||
|
return sid, sub, t.sessionCreatedAtForInvalidation("", session)
|
||||||
|
}
|
||||||
|
|
||||||
// extractSessionInfo extracts sid and sub from an ID token for session tracking
|
// extractSessionInfo extracts sid and sub from an ID token for session tracking
|
||||||
func (t *TraefikOidc) extractSessionInfo(idToken string) (sid, sub string, createdAt time.Time) {
|
func (t *TraefikOidc) extractSessionInfo(idToken string) (sid, sub string, createdAt time.Time) {
|
||||||
if idToken == "" {
|
if idToken == "" {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
)
|
||||||
|
|
||||||
|
// invalidationInputs must return exactly what extractSessionInfo and
|
||||||
|
// sessionCreatedAtForInvalidation return when called separately.
|
||||||
|
func TestInvalidationInputsMatchSeparateCalls(t *testing.T) {
|
||||||
|
oidc := &TraefikOidc{logger: newNoOpLogger()}
|
||||||
|
sess := &SessionData{mainSession: &sessions.Session{Values: map[interface{}]interface{}{"created_at": int64(1700000000)}}}
|
||||||
|
|
||||||
|
tokens := map[string]string{
|
||||||
|
"all claims": makeTestJWT(t, map[string]interface{}{"sid": "s1", "sub": "u1", "iat": float64(1700000100)}),
|
||||||
|
"no iat": makeTestJWT(t, map[string]interface{}{"sid": "s1", "sub": "u1"}),
|
||||||
|
"sid only": makeTestJWT(t, map[string]interface{}{"sid": "s1", "iat": float64(1700000200)}),
|
||||||
|
"sub only": makeTestJWT(t, map[string]interface{}{"sub": "u1"}),
|
||||||
|
"wrong types": makeTestJWT(t, map[string]interface{}{"sid": 5, "sub": true, "iat": "x"}),
|
||||||
|
"malformed": "not-a-jwt",
|
||||||
|
"empty": "",
|
||||||
|
}
|
||||||
|
for name, tok := range tokens {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
wantSID, wantSub, _ := oidc.extractSessionInfo(tok)
|
||||||
|
wantAt := oidc.sessionCreatedAtForInvalidation(tok, sess)
|
||||||
|
gotSID, gotSub, gotAt := oidc.invalidationInputs(tok, sess)
|
||||||
|
if gotSID != wantSID || gotSub != wantSub || !gotAt.Equal(wantAt) {
|
||||||
|
t.Fatalf("got (%q,%q,%v) want (%q,%q,%v)", gotSID, gotSub, gotAt, wantSID, wantSub, wantAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSessionInvalidationKeyScopedPerInstance(t *testing.T) {
|
||||||
|
a := &TraefikOidc{tokenCache: &TokenCache{}}
|
||||||
|
b := &TraefikOidc{tokenCache: &TokenCache{}}
|
||||||
|
a.tokenCache.SetNamespace("https://kc/realms/a|c|c")
|
||||||
|
b.tokenCache.SetNamespace("https://kc/realms/b|c|c")
|
||||||
|
for _, kind := range []string{"sub", "sid", "jti"} {
|
||||||
|
if a.buildSessionInvalidationKey(kind, "u1") == b.buildSessionInvalidationKey(kind, "u1") {
|
||||||
|
t.Errorf("%s key shared across issuers", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := (&TraefikOidc{}).buildSessionInvalidationKey("sub", "u1"); got != "session_invalidation:sub:u1" {
|
||||||
|
t.Errorf("namespace-less key changed: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -548,6 +548,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
|
|||||||
logger.Debugf("TraefikOidc.New: logoutURLPath configured as: %q", t.logoutURLPath)
|
logger.Debugf("TraefikOidc.New: logoutURLPath configured as: %q", t.logoutURLPath)
|
||||||
|
|
||||||
t.providerURL = config.ProviderURL
|
t.providerURL = config.ProviderURL
|
||||||
|
t.tokenCache.SetNamespace(t.tokenCacheNamespace())
|
||||||
|
|
||||||
// Use singleton resource manager for metadata initialization
|
// Use singleton resource manager for metadata initialization
|
||||||
rm := GetResourceManager()
|
rm := GetResourceManager()
|
||||||
@@ -944,6 +945,7 @@ func (t *TraefikOidc) performDynamicClientRegistration() {
|
|||||||
if t.audience == "" {
|
if t.audience == "" {
|
||||||
t.audience = resp.ClientID // Default audience to client ID
|
t.audience = resp.ClientID // Default audience to client ID
|
||||||
}
|
}
|
||||||
|
t.tokenCache.SetNamespace(t.tokenCacheNamespace())
|
||||||
// R162: DCR can provision a client whose method is private_key_jwt
|
// R162: DCR can provision a client whose method is private_key_jwt
|
||||||
// (RFC 7591 ClientMetadata.TokenEndpointAuthMethod) even when the
|
// (RFC 7591 ClientMetadata.TokenEndpointAuthMethod) even when the
|
||||||
// static config did not set ClientAuthMethod=private_key_jwt. In that
|
// static config did not set ClientAuthMethod=private_key_jwt. In that
|
||||||
@@ -1083,3 +1085,8 @@ func buildFullURL(scheme, host, path string) string {
|
|||||||
|
|
||||||
return fmt.Sprintf("%s://%s%s", scheme, host, path)
|
return fmt.Sprintf("%s://%s%s", scheme, host, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tokenCacheNamespace identifies the verification context of this instance.
|
||||||
|
func (t *TraefikOidc) tokenCacheNamespace() string {
|
||||||
|
return t.providerURL + "|" + t.clientID + "|" + t.audience
|
||||||
|
}
|
||||||
|
|||||||
+39
-4
@@ -158,6 +158,15 @@ func (w *optionsPreflightWriter) Write(b []byte) (int, error) {
|
|||||||
return len(b), nil
|
return len(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maxInitWaiters bounds requests parked on initComplete so a boot-time
|
||||||
|
// flood with the IdP down cannot hold unbounded connections for 30s each.
|
||||||
|
maxInitWaiters = 512
|
||||||
|
// initWaitRetryAfter is the Retry-After value (seconds) sent when the
|
||||||
|
// init-wait queue is full.
|
||||||
|
initWaitRetryAfter = "5"
|
||||||
|
)
|
||||||
|
|
||||||
// bypassReason describes why a request is being forwarded without OIDC auth.
|
// bypassReason describes why a request is being forwarded without OIDC auth.
|
||||||
// It is only used for logging and to decide whether extra side-effects
|
// It is only used for logging and to decide whether extra side-effects
|
||||||
// (propagating the user header from an existing session) should run.
|
// (propagating the user header from an existing session) should run.
|
||||||
@@ -701,6 +710,13 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
// Log waiting for initialization to help diagnose hanging requests
|
// Log waiting for initialization to help diagnose hanging requests
|
||||||
t.logger.Debug("Waiting for OIDC provider initialization...")
|
t.logger.Debug("Waiting for OIDC provider initialization...")
|
||||||
|
|
||||||
|
if atomic.AddInt32(&t.initWaiters, 1) > maxInitWaiters {
|
||||||
|
atomic.AddInt32(&t.initWaiters, -1)
|
||||||
|
rw.Header().Set("Retry-After", initWaitRetryAfter)
|
||||||
|
t.sendErrorResponse(rw, req, "OIDC provider initialization in progress - please try again later", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// time.NewTimer + Stop avoids leaking a goroutine+channel for 30s on every
|
// time.NewTimer + Stop avoids leaking a goroutine+channel for 30s on every
|
||||||
// request when initComplete fires quickly (would happen with time.After).
|
// request when initComplete fires quickly (would happen with time.After).
|
||||||
initTimer := time.NewTimer(30 * time.Second)
|
initTimer := time.NewTimer(30 * time.Second)
|
||||||
@@ -708,6 +724,7 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case <-t.initComplete:
|
case <-t.initComplete:
|
||||||
|
atomic.AddInt32(&t.initWaiters, -1)
|
||||||
// Read issuerURL via atomic snapshot when available — replaces the
|
// Read issuerURL via atomic snapshot when available — replaces the
|
||||||
// metadataMu.RLock that previously fired on every non-bypass request.
|
// metadataMu.RLock that previously fired on every non-bypass request.
|
||||||
// Under Yaegi each RLock acquisition costs 1-5ms of interpreter
|
// Under Yaegi each RLock acquisition costs 1-5ms of interpreter
|
||||||
@@ -744,10 +761,12 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case <-req.Context().Done():
|
case <-req.Context().Done():
|
||||||
|
atomic.AddInt32(&t.initWaiters, -1)
|
||||||
t.logger.Debug("Request canceled while waiting for OIDC initialization")
|
t.logger.Debug("Request canceled while waiting for OIDC initialization")
|
||||||
t.sendErrorResponse(rw, req, "Request canceled", http.StatusRequestTimeout)
|
t.sendErrorResponse(rw, req, "Request canceled", http.StatusRequestTimeout)
|
||||||
return
|
return
|
||||||
case <-initTimer.C:
|
case <-initTimer.C:
|
||||||
|
atomic.AddInt32(&t.initWaiters, -1)
|
||||||
t.logger.Error("Timeout waiting for OIDC initialization")
|
t.logger.Error("Timeout waiting for OIDC initialization")
|
||||||
t.sendErrorResponse(rw, req, "Timeout waiting for OIDC provider initialization - please try again later", http.StatusServiceUnavailable)
|
t.sendErrorResponse(rw, req, "Timeout waiting for OIDC provider initialization - please try again later", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
@@ -1035,8 +1054,7 @@ func (t *TraefikOidc) processAuthorizedRequestRS(rw http.ResponseWriter, req *ht
|
|||||||
idToken := rs.idToken
|
idToken := rs.idToken
|
||||||
if t.enableBackchannelLogout || t.enableFrontchannelLogout {
|
if t.enableBackchannelLogout || t.enableFrontchannelLogout {
|
||||||
if idToken != "" {
|
if idToken != "" {
|
||||||
sid, sub, _ := t.extractSessionInfo(idToken)
|
sid, sub, createdAt := t.invalidationInputs(idToken, session)
|
||||||
createdAt := t.sessionCreatedAtForInvalidation(idToken, session)
|
|
||||||
if t.isSessionInvalidated(sid, sub, createdAt) {
|
if t.isSessionInvalidated(sid, sub, createdAt) {
|
||||||
t.logger.Infof("Session for user %s has been invalidated via IdP-initiated logout", userIdentifier)
|
t.logger.Infof("Session for user %s has been invalidated via IdP-initiated logout", userIdentifier)
|
||||||
if err := session.Clear(req, rw); err != nil {
|
if err := session.Clear(req, rw); err != nil {
|
||||||
@@ -1128,8 +1146,7 @@ func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http
|
|||||||
if t.enableBackchannelLogout || t.enableFrontchannelLogout {
|
if t.enableBackchannelLogout || t.enableFrontchannelLogout {
|
||||||
idToken := session.GetIDToken()
|
idToken := session.GetIDToken()
|
||||||
if idToken != "" {
|
if idToken != "" {
|
||||||
sid, sub, _ := t.extractSessionInfo(idToken)
|
sid, sub, createdAt := t.invalidationInputs(idToken, session)
|
||||||
createdAt := t.sessionCreatedAtForInvalidation(idToken, session)
|
|
||||||
if t.isSessionInvalidated(sid, sub, createdAt) {
|
if t.isSessionInvalidated(sid, sub, createdAt) {
|
||||||
t.logger.Infof("Session for user %s has been invalidated via IdP-initiated logout", userIdentifier)
|
t.logger.Infof("Session for user %s has been invalidated via IdP-initiated logout", userIdentifier)
|
||||||
// Clear the session and redirect to login
|
// Clear the session and redirect to login
|
||||||
@@ -1324,6 +1341,23 @@ func joinBoundedClaimHeader(values []string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dropInboundTemplated removes a client-supplied value for a header the
|
||||||
|
// middleware owns, so a skipped render leaves it absent instead of forwarding
|
||||||
|
// the client's. Identity headers (already stripped and re-injected) and
|
||||||
|
// Authorization (the inbound bearer credential) are left alone.
|
||||||
|
func dropInboundTemplated(req *http.Request, name string) {
|
||||||
|
canon := http.CanonicalHeaderKey(name)
|
||||||
|
if canon == "Authorization" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, h := range identityHeaders {
|
||||||
|
if canon == http.CanonicalHeaderKey(h) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.Header.Del(canon)
|
||||||
|
}
|
||||||
|
|
||||||
// applyHeaderTemplates renders the configured header templates for p onto req.
|
// applyHeaderTemplates renders the configured header templates for p onto req.
|
||||||
func (t *TraefikOidc) applyHeaderTemplates(req *http.Request, p *principal) {
|
func (t *TraefikOidc) applyHeaderTemplates(req *http.Request, p *principal) {
|
||||||
if len(t.headerTemplates) == 0 {
|
if len(t.headerTemplates) == 0 {
|
||||||
@@ -1342,6 +1376,7 @@ func (t *TraefikOidc) applyHeaderTemplates(req *http.Request, p *principal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for headerName, tmpl := range t.headerTemplates {
|
for headerName, tmpl := range t.headerTemplates {
|
||||||
|
dropInboundTemplated(req, headerName)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := tmpl.Execute(&buf, templateData); err != nil {
|
if err := tmpl.Execute(&buf, templateData); err != nil {
|
||||||
t.logger.Errorf("Failed to execute template for header %s: %v", headerName, err)
|
t.logger.Errorf("Failed to execute template for header %s: %v", headerName, err)
|
||||||
|
|||||||
+19
-2
@@ -62,10 +62,11 @@ func (l *perSourceAuthLimiter) allow(req *http.Request) bool {
|
|||||||
if now.Sub(l.lastSweep) >= time.Minute {
|
if now.Sub(l.lastSweep) >= time.Minute {
|
||||||
l.sweep(now)
|
l.sweep(now)
|
||||||
}
|
}
|
||||||
e := l.entries[ip]
|
key := limiterKey(ip)
|
||||||
|
e := l.entries[key]
|
||||||
if e == nil {
|
if e == nil {
|
||||||
e = &perSourceAuthEntry{limiter: rate.NewLimiter(l.limit, l.burst)}
|
e = &perSourceAuthEntry{limiter: rate.NewLimiter(l.limit, l.burst)}
|
||||||
l.entries[ip] = e
|
l.entries[key] = e
|
||||||
}
|
}
|
||||||
e.lastSeen = now
|
e.lastSeen = now
|
||||||
return e.limiter.Allow()
|
return e.limiter.Allow()
|
||||||
@@ -115,3 +116,19 @@ func isInternalSource(ip string) bool {
|
|||||||
return p.IsLoopback() || p.IsPrivate() || p.IsLinkLocalUnicast() ||
|
return p.IsLoopback() || p.IsPrivate() || p.IsLinkLocalUnicast() ||
|
||||||
p.IsLinkLocalMulticast() || p.IsUnspecified()
|
p.IsLinkLocalMulticast() || p.IsUnspecified()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ipv6PrefixBits is the IPv6 prefix that shares one bucket: a host holds a
|
||||||
|
// whole /64, so per-address keys would give it 2^64 buckets.
|
||||||
|
const ipv6PrefixBits = 64
|
||||||
|
|
||||||
|
// limiterKey maps a source IP to its bucket key (IPv6 collapses to its /64).
|
||||||
|
func limiterKey(ip string) string {
|
||||||
|
p := net.ParseIP(ip)
|
||||||
|
if p == nil {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
if p.To4() != nil {
|
||||||
|
return p.String()
|
||||||
|
}
|
||||||
|
return (&net.IPNet{IP: p.Mask(net.CIDRMask(ipv6PrefixBits, 128)), Mask: net.CIDRMask(ipv6PrefixBits, 128)}).String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An IPv6 /64 holder must share one bucket; per-address keys let one host
|
||||||
|
// mint 2^64 buckets and bypass the throttle.
|
||||||
|
func TestPerSourceLimiter_IPv6SamePrefixSharesBucket(t *testing.T) {
|
||||||
|
const burst = 3
|
||||||
|
l := newPerSourceAuthLimiter(burst)
|
||||||
|
allowed := 0
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.RemoteAddr = fmt.Sprintf("[2001:db8:1:2::%x]:1234", i+1)
|
||||||
|
if l.allow(req) {
|
||||||
|
allowed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allowed > burst {
|
||||||
|
t.Fatalf("one /64 got %d allows, want <= %d", allowed, burst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPerSourceLimiter_IPv6DifferentPrefixIndependent(t *testing.T) {
|
||||||
|
l := newPerSourceAuthLimiter(1)
|
||||||
|
a := httptest.NewRequest("GET", "/", nil)
|
||||||
|
a.RemoteAddr = "[2001:db8:1:2::1]:1"
|
||||||
|
b := httptest.NewRequest("GET", "/", nil)
|
||||||
|
b.RemoteAddr = "[2001:db8:1:3::1]:1"
|
||||||
|
if !l.allow(a) || !l.allow(b) {
|
||||||
|
t.Fatal("distinct /64 prefixes must have independent buckets")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimiterKey(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"192.0.2.1": "192.0.2.1",
|
||||||
|
"2001:db8:1:2::9": "2001:db8:1:2::/64",
|
||||||
|
"::ffff:192.0.2.1": "192.0.2.1",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := limiterKey(in); got != want {
|
||||||
|
t.Errorf("limiterKey(%q)=%q want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cleanup must not evict a tracker whose cooldown is still running.
|
||||||
|
func TestRefreshCleanupKeepsTrackerInCooldown(t *testing.T) {
|
||||||
|
cfg := DefaultRefreshCoordinatorConfig()
|
||||||
|
cfg.MaxRefreshAttempts = 2
|
||||||
|
cfg.RefreshAttemptWindow = 20 * time.Millisecond
|
||||||
|
cfg.RefreshCooldownPeriod = time.Hour
|
||||||
|
rc := NewRefreshCoordinator(cfg, GetSingletonNoOpLogger())
|
||||||
|
defer rc.Shutdown()
|
||||||
|
|
||||||
|
rc.recordRefreshAttempt("s")
|
||||||
|
rc.recordRefreshAttempt("s")
|
||||||
|
if !rc.isInCooldown("s") {
|
||||||
|
t.Fatal("precondition: session must enter cooldown")
|
||||||
|
}
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
rc.cleanupStaleEntries()
|
||||||
|
if !rc.isInCooldown("s") {
|
||||||
|
t.Fatal("cleanup evicted a tracker still in cooldown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale chunks above token_total must be dropped by key, even when the key
|
||||||
|
// set is sparse.
|
||||||
|
func TestChunkTrimDropsStaleSparseKeys(t *testing.T) {
|
||||||
|
cm := NewChunkManager(newNoOpLogger())
|
||||||
|
mk := func(s string, total interface{}) *sessions.Session {
|
||||||
|
v := map[interface{}]interface{}{"token_chunk": s}
|
||||||
|
if total != nil {
|
||||||
|
v["token_total"] = total
|
||||||
|
}
|
||||||
|
return &sessions.Session{Values: v}
|
||||||
|
}
|
||||||
|
chunks := map[int]*sessions.Session{
|
||||||
|
0: mk("Zq8xK2mP", 3), 1: mk("r7TnB4wY", nil), 2: mk("c9LdG5sH", nil), 7: mk("stale", nil),
|
||||||
|
}
|
||||||
|
res := cm.processChunkedToken(chunks, RefreshTokenConfig)
|
||||||
|
if res.Error != nil || res.Token != "Zq8xK2mPr7TnB4wYc9LdG5sH" {
|
||||||
|
t.Fatalf("token=%q err=%v, want Zq8xK2mPr7TnB4wYc9LdG5sH", res.Token, res.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-13
@@ -672,7 +672,6 @@ func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool {
|
|||||||
}
|
}
|
||||||
return &attemptState{
|
return &attemptState{
|
||||||
windowStartNano: nowNano,
|
windowStartNano: nowNano,
|
||||||
attempts: 1,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@@ -686,7 +685,7 @@ func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool {
|
|||||||
}
|
}
|
||||||
next := *s
|
next := *s
|
||||||
next.windowStartNano = nowNano
|
next.windowStartNano = nowNano
|
||||||
next.attempts = 1
|
next.attempts = 0
|
||||||
return &next
|
return &next
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@@ -824,13 +823,16 @@ func (rc *RefreshCoordinator) cleanupRoutine() {
|
|||||||
// cleanupStaleEntries removes outdated tracking entries. Lock-free iteration
|
// cleanupStaleEntries removes outdated tracking entries. Lock-free iteration
|
||||||
// via sync.Map.Range; safe to race with concurrent reads/writes.
|
// via sync.Map.Range; safe to race with concurrent reads/writes.
|
||||||
func (rc *RefreshCoordinator) cleanupStaleEntries() {
|
func (rc *RefreshCoordinator) cleanupStaleEntries() {
|
||||||
cutoff := time.Now().Add(-2 * rc.config.RefreshAttemptWindow).UnixNano()
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-2 * rc.config.RefreshAttemptWindow).UnixNano()
|
||||||
|
nowNano := now.UnixNano()
|
||||||
rc.sessionRefreshAttempts.Range(func(key, value interface{}) bool {
|
rc.sessionRefreshAttempts.Range(func(key, value interface{}) bool {
|
||||||
tracker := trackerFromMapValue(value)
|
tracker := trackerFromMapValue(value)
|
||||||
if tracker == nil {
|
if tracker == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if tracker.stateOf().lastAttemptNano < cutoff {
|
st := tracker.stateOf()
|
||||||
|
if st.lastAttemptNano < cutoff && st.cooldownEndNano <= nowNano {
|
||||||
// Compare-and-delete to avoid evicting a tracker that was just
|
// Compare-and-delete to avoid evicting a tracker that was just
|
||||||
// re-used by a concurrent caller. We compare by pointer identity.
|
// re-used by a concurrent caller. We compare by pointer identity.
|
||||||
rc.sessionRefreshAttempts.CompareAndDelete(key, value)
|
rc.sessionRefreshAttempts.CompareAndDelete(key, value)
|
||||||
@@ -939,20 +941,28 @@ func (cb *RefreshCircuitBreaker) AllowRequest() bool {
|
|||||||
return true
|
return true
|
||||||
case 1: // open
|
case 1: // open
|
||||||
lastFail := atomic.LoadInt64(&cb.lastFailureNano)
|
lastFail := atomic.LoadInt64(&cb.lastFailureNano)
|
||||||
if time.Duration(time.Now().UnixNano()-lastFail) > cb.config.OpenDuration {
|
return cb.claimProbe(lastFail)
|
||||||
// Transition to half-open; first CAS winner gets the probe.
|
case 2: // half-open: one probe at a time; a stale probe is replaced
|
||||||
if atomic.CompareAndSwapInt32(&cb.state, 1, 2) {
|
return cb.claimProbe(atomic.LoadInt64(&cb.lastFailureNano))
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
case 2: // half-open
|
|
||||||
return true
|
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// claimProbe admits one probe when OpenDuration has passed since lastFail.
|
||||||
|
// The CAS on lastFailureNano makes the claim exclusive and restarts the clock.
|
||||||
|
func (cb *RefreshCircuitBreaker) claimProbe(lastFail int64) bool {
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
if time.Duration(now-lastFail) <= cb.config.OpenDuration {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !atomic.CompareAndSwapInt64(&cb.lastFailureNano, lastFail, now) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
atomic.StoreInt32(&cb.state, 2)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// RecordSuccess records a successful operation. Lock-free.
|
// RecordSuccess records a successful operation. Lock-free.
|
||||||
func (cb *RefreshCircuitBreaker) RecordSuccess() {
|
func (cb *RefreshCircuitBreaker) RecordSuccess() {
|
||||||
switch atomic.LoadInt32(&cb.state) {
|
switch atomic.LoadInt32(&cb.state) {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// After a window reset the tracker must allow the full MaxRefreshAttempts,
|
||||||
|
// the same as a fresh tracker.
|
||||||
|
func TestRefreshWindowResetAllowsFullQuota(t *testing.T) {
|
||||||
|
cfg := DefaultRefreshCoordinatorConfig()
|
||||||
|
cfg.MaxRefreshAttempts = 3
|
||||||
|
cfg.RefreshAttemptWindow = 50 * time.Millisecond
|
||||||
|
cfg.RefreshCooldownPeriod = time.Hour
|
||||||
|
rc := NewRefreshCoordinator(cfg, GetSingletonNoOpLogger())
|
||||||
|
defer rc.Shutdown()
|
||||||
|
|
||||||
|
pass := func() int {
|
||||||
|
n := 0
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
if rc.isInCooldown("s") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rc.recordRefreshAttempt("s")
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
if got := pass(); got != 3 {
|
||||||
|
t.Fatalf("fresh window passes=%d want 3", got)
|
||||||
|
}
|
||||||
|
// Window expiry alone cannot reset while in cooldown, so use a new
|
||||||
|
// session that never hits the cap: record 1, wait out the window.
|
||||||
|
rc.recordRefreshAttempt("w")
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
n := 0
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
if rc.isInCooldown("w") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rc.recordRefreshAttempt("w")
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if n != 3 {
|
||||||
|
t.Fatalf("post-reset window passes=%d want 3", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Half-open admits a single probe until it reports.
|
||||||
|
func TestRefreshBreakerHalfOpenSingleProbe(t *testing.T) {
|
||||||
|
cb := &RefreshCircuitBreaker{config: RefreshCircuitBreakerConfig{
|
||||||
|
MaxFailures: 1, OpenDuration: 20 * time.Millisecond, HalfOpenRequests: 1,
|
||||||
|
}}
|
||||||
|
cb.RecordFailure()
|
||||||
|
time.Sleep(40 * time.Millisecond)
|
||||||
|
var admitted int32
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if cb.AllowRequest() {
|
||||||
|
atomic.AddInt32(&admitted, 1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if admitted != 1 {
|
||||||
|
t.Fatalf("half-open admitted %d, want 1", admitted)
|
||||||
|
}
|
||||||
|
cb.RecordSuccess()
|
||||||
|
if !cb.AllowRequest() {
|
||||||
|
t.Fatal("breaker must close after probe success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A probe that never reports must not wedge the breaker.
|
||||||
|
func TestRefreshBreakerHalfOpenProbeTimeout(t *testing.T) {
|
||||||
|
cb := &RefreshCircuitBreaker{config: RefreshCircuitBreakerConfig{
|
||||||
|
MaxFailures: 1, OpenDuration: 20 * time.Millisecond, HalfOpenRequests: 1,
|
||||||
|
}}
|
||||||
|
cb.RecordFailure()
|
||||||
|
time.Sleep(40 * time.Millisecond)
|
||||||
|
if !cb.AllowRequest() || cb.AllowRequest() {
|
||||||
|
t.Fatal("want exactly one probe")
|
||||||
|
}
|
||||||
|
time.Sleep(40 * time.Millisecond)
|
||||||
|
if !cb.AllowRequest() {
|
||||||
|
t.Fatal("stale probe must allow a new one")
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-1
@@ -116,6 +116,10 @@ const (
|
|||||||
defaultCookiePrefix = "_oidc_raczylo_"
|
defaultCookiePrefix = "_oidc_raczylo_"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxCleanupCookies bounds the cookie names CleanupOldCookies emits deletion
|
||||||
|
// headers for per response (up to 4 Set-Cookie headers each).
|
||||||
|
const maxCleanupCookies = 32
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// maxCookieSize is the maximum raw data size per chunk before securecookie encoding.
|
// maxCookieSize is the maximum raw data size per chunk before securecookie encoding.
|
||||||
//
|
//
|
||||||
@@ -937,6 +941,9 @@ func (sm *SessionManager) EnhanceSessionSecurity(options *sessions.Options, r *h
|
|||||||
// service). Keep it unless it is a strict parent of the serving
|
// service). Keep it unless it is a strict parent of the serving
|
||||||
// host, which would scope the token-bearing cookie to sibling
|
// host, which would scope the token-bearing cookie to sibling
|
||||||
// subdomains — then fall back to the serving host.
|
// subdomains — then fall back to the serving host.
|
||||||
|
// DNS names and the cookie Domain attribute are case-insensitive.
|
||||||
|
host = strings.ToLower(host)
|
||||||
|
fh = strings.ToLower(fh)
|
||||||
if !strings.HasSuffix(host, "."+fh) {
|
if !strings.HasSuffix(host, "."+fh) {
|
||||||
host = fh
|
host = fh
|
||||||
}
|
}
|
||||||
@@ -1024,6 +1031,7 @@ func (sm *SessionManager) CleanupOldCookies(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
|
|
||||||
processedCookies := make(map[string]bool)
|
processedCookies := make(map[string]bool)
|
||||||
|
deletedCookies := 0
|
||||||
|
|
||||||
for _, cookie := range cookies {
|
for _, cookie := range cookies {
|
||||||
// Check if cookie belongs to this middleware instance using the configured prefix
|
// Check if cookie belongs to this middleware instance using the configured prefix
|
||||||
@@ -1037,7 +1045,8 @@ func (sm *SessionManager) CleanupOldCookies(w http.ResponseWriter, r *http.Reque
|
|||||||
shouldCleanup := currentDomain != "" && !sm.cleanupDone
|
shouldCleanup := currentDomain != "" && !sm.cleanupDone
|
||||||
sm.cleanupMutex.RUnlock()
|
sm.cleanupMutex.RUnlock()
|
||||||
|
|
||||||
if shouldCleanup {
|
if shouldCleanup && deletedCookies < maxCleanupCookies {
|
||||||
|
deletedCookies++
|
||||||
for _, domain := range domainsToClean {
|
for _, domain := range domainsToClean {
|
||||||
if domain == currentDomain || domain == "."+currentDomain || "."+domain == currentDomain {
|
if domain == currentDomain || domain == "."+currentDomain || "."+domain == currentDomain {
|
||||||
continue
|
continue
|
||||||
@@ -2293,6 +2302,13 @@ func (sd *SessionData) SetAccessToken(token string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chunk cookies bind to the request; without one the store cannot
|
||||||
|
// complete, so keep the previous token.
|
||||||
|
if sd.request == nil {
|
||||||
|
sd.manager.logger.Error("SetAccessToken: sd.request is nil, cannot create chunk sessions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Validation passed: now it is safe to clear the existing token
|
// Validation passed: now it is safe to clear the existing token
|
||||||
// before storing the new chunked one.
|
// before storing the new chunked one.
|
||||||
sd.clearAccessTokenChunks()
|
sd.clearAccessTokenChunks()
|
||||||
@@ -2928,6 +2944,9 @@ func (sd *SessionData) GetIncomingPath() string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxIncomingPathLen bounds the post-login redirect path kept in the session.
|
||||||
|
const maxIncomingPathLen = 2048
|
||||||
|
|
||||||
// SetIncomingPath stores the original request URI for post-authentication redirect.
|
// SetIncomingPath stores the original request URI for post-authentication redirect.
|
||||||
// This allows the user to be redirected to their originally requested resource
|
// This allows the user to be redirected to their originally requested resource
|
||||||
// after completing the authentication flow.
|
// after completing the authentication flow.
|
||||||
@@ -2936,6 +2955,11 @@ func (sd *SessionData) GetIncomingPath() string {
|
|||||||
func (sd *SessionData) SetIncomingPath(path string) {
|
func (sd *SessionData) SetIncomingPath(path string) {
|
||||||
sd.sessionMutex.Lock()
|
sd.sessionMutex.Lock()
|
||||||
defer sd.sessionMutex.Unlock()
|
defer sd.sessionMutex.Unlock()
|
||||||
|
// The path becomes cookie bytes on every later request; an oversized one
|
||||||
|
// would bloat the Cookie header or fail the save. Fall back to "/".
|
||||||
|
if len(path) > maxIncomingPathLen {
|
||||||
|
path = ""
|
||||||
|
}
|
||||||
currentVal, _ := sd.mainSession.Values["incoming_path"].(string)
|
currentVal, _ := sd.mainSession.Values["incoming_path"].(string)
|
||||||
if currentVal != path {
|
if currentVal != path {
|
||||||
sd.mainSession.Values["incoming_path"] = path
|
sd.mainSession.Values["incoming_path"] = path
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A chunked store that cannot run (no request to bind chunk cookies to) must
|
||||||
|
// leave the previous access token intact.
|
||||||
|
func TestSetAccessTokenNilRequestKeepsOldToken(t *testing.T) {
|
||||||
|
sm, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("error"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sess, err := sm.GetSession(httptest.NewRequest("GET", "/", nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(sess.returnToPoolSafely)
|
||||||
|
|
||||||
|
const old = "smallaccesstokenvaluevaluevalue"
|
||||||
|
sess.SetAccessToken(old)
|
||||||
|
|
||||||
|
blob := make([]byte, 3000)
|
||||||
|
if _, err := rand.Read(blob); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sess.request = nil
|
||||||
|
sess.SetAccessToken(base64.StdEncoding.EncodeToString(blob))
|
||||||
|
|
||||||
|
if got := sess.GetAccessToken(); got != old {
|
||||||
|
t.Fatalf("old access token lost after a failed chunked store: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-226
@@ -32,7 +32,6 @@ type TokenConfig struct {
|
|||||||
// Global session tracking to prevent memory leaks across all instances
|
// Global session tracking to prevent memory leaks across all instances
|
||||||
var (
|
var (
|
||||||
globalSessionCount int64 = 0
|
globalSessionCount int64 = 0
|
||||||
globalMaxSessions int64 = 5000 // CRITICAL FIX: Global limit of 5000 total sessions
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResetGlobalSessionCounters resets global session tracking for testing
|
// ResetGlobalSessionCounters resets global session tracking for testing
|
||||||
@@ -445,9 +444,10 @@ func (cm *ChunkManager) processChunkedToken(chunks map[int]*sessions.Session, co
|
|||||||
// not clear cookies above n). token_total is the authoritative
|
// not clear cookies above n). token_total is the authoritative
|
||||||
// count; drop the excess rather than failing — which forced a
|
// count; drop the excess rather than failing — which forced a
|
||||||
// re-auth and discarded the still-valid new token (R129).
|
// re-auth and discarded the still-valid new token (R129).
|
||||||
n := len(chunks)
|
for i := range chunks {
|
||||||
for i := total; i < n; i++ {
|
if i >= total {
|
||||||
delete(chunks, i)
|
delete(chunks, i)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(chunks) < total {
|
if len(chunks) < total {
|
||||||
@@ -1242,226 +1242,4 @@ func (cm *ChunkManager) CleanupExpiredSessions(force ...bool) {
|
|||||||
cm.logger.Debugf("Cleaned up %d expired sessions, freed %d bytes",
|
cm.logger.Debugf("Cleaned up %d expired sessions, freed %d bytes",
|
||||||
len(expiredKeys), totalBytesFreed)
|
len(expiredKeys), totalBytesFreed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce max sessions limit
|
|
||||||
if len(cm.sessionMap) > cm.maxSessions {
|
|
||||||
cm.enforceSessionLimit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// enforceSessionLimit removes oldest sessions when limit is exceeded
|
|
||||||
// Uses partial sort (O(n) for finding k smallest) instead of full sort (O(n log n))
|
|
||||||
func (cm *ChunkManager) enforceSessionLimit() {
|
|
||||||
currentLocal := len(cm.sessionMap)
|
|
||||||
currentGlobal := atomic.LoadInt64(&globalSessionCount)
|
|
||||||
|
|
||||||
// CRITICAL FIX: Aggressive eviction when approaching limits
|
|
||||||
shouldEvict := false
|
|
||||||
targetCapacity := cm.maxSessions
|
|
||||||
|
|
||||||
// Check global limit first (more critical)
|
|
||||||
if currentGlobal >= globalMaxSessions {
|
|
||||||
shouldEvict = true
|
|
||||||
targetCapacity = cm.maxSessions / 4 // Aggressive reduction to 25%
|
|
||||||
} else if currentGlobal >= globalMaxSessions*8/10 { // 80% of global
|
|
||||||
shouldEvict = true
|
|
||||||
targetCapacity = cm.maxSessions / 2 // Reduce to 50%
|
|
||||||
} else if currentLocal >= cm.maxSessions {
|
|
||||||
shouldEvict = true
|
|
||||||
targetCapacity = cm.maxSessions * 3 / 4 // Reduce to 75%
|
|
||||||
}
|
|
||||||
|
|
||||||
if !shouldEvict {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate how many sessions to remove
|
|
||||||
excessCount := currentLocal - targetCapacity
|
|
||||||
if excessCount <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use partial selection instead of full sort for better performance
|
|
||||||
// For finding k oldest sessions, we only need O(n) operations
|
|
||||||
keysToRemove := cm.findOldestSessions(excessCount)
|
|
||||||
|
|
||||||
totalBytesFreed := int64(0)
|
|
||||||
removedCount := int64(0)
|
|
||||||
|
|
||||||
for _, key := range keysToRemove {
|
|
||||||
if entry, exists := cm.sessionMap[key]; exists {
|
|
||||||
totalBytesFreed += entry.SizeEstimate
|
|
||||||
atomic.AddInt64(&cm.bytesAllocated, -entry.SizeEstimate)
|
|
||||||
removedCount++
|
|
||||||
}
|
|
||||||
delete(cm.sessionMap, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update global count
|
|
||||||
if removedCount > 0 {
|
|
||||||
atomic.AddInt64(&globalSessionCount, -removedCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
cm.logger.Infof("Enforced session limit: removed %d excess sessions, freed %d bytes",
|
|
||||||
len(keysToRemove), totalBytesFreed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// findOldestSessions returns keys of the k oldest sessions efficiently
|
|
||||||
// Uses a simple approach: find the kth oldest timestamp, then collect all older entries
|
|
||||||
func (cm *ChunkManager) findOldestSessions(k int) []string {
|
|
||||||
if k <= 0 || len(cm.sessionMap) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if k >= len(cm.sessionMap) {
|
|
||||||
// Remove all sessions
|
|
||||||
keys := make([]string, 0, len(cm.sessionMap))
|
|
||||||
for key := range cm.sessionMap {
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all timestamps with keys
|
|
||||||
type sessionAge struct {
|
|
||||||
lastUsed time.Time
|
|
||||||
key string
|
|
||||||
}
|
|
||||||
|
|
||||||
sessions := make([]sessionAge, 0, len(cm.sessionMap))
|
|
||||||
for key, entry := range cm.sessionMap {
|
|
||||||
sessions = append(sessions, sessionAge{key: key, lastUsed: entry.LastUsed})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Partial sort: get the k smallest elements using selection
|
|
||||||
// This is O(n*k) which is better than O(n log n) when k << n
|
|
||||||
for i := 0; i < k; i++ {
|
|
||||||
minIdx := i
|
|
||||||
for j := i + 1; j < len(sessions); j++ {
|
|
||||||
if sessions[j].lastUsed.Before(sessions[minIdx].lastUsed) {
|
|
||||||
minIdx = j
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if minIdx != i {
|
|
||||||
sessions[i], sessions[minIdx] = sessions[minIdx], sessions[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the k oldest
|
|
||||||
result := make([]string, k)
|
|
||||||
for i := 0; i < k; i++ {
|
|
||||||
result[i] = sessions[i].key
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// CanCreateSession checks if a new session can be created within limits
|
|
||||||
func (cm *ChunkManager) CanCreateSession() (bool, error) {
|
|
||||||
cm.mutex.RLock()
|
|
||||||
currentCount := len(cm.sessionMap)
|
|
||||||
cm.mutex.RUnlock()
|
|
||||||
|
|
||||||
// Hard limit check - never exceed maxSessions
|
|
||||||
if currentCount >= cm.maxSessions {
|
|
||||||
cm.logger.Error("Cannot create session: at maximum limit (%d)", cm.maxSessions)
|
|
||||||
return false, fmt.Errorf("session storage at maximum capacity (%d sessions)", cm.maxSessions)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emergency cleanup at 90% capacity
|
|
||||||
emergencyThreshold := int(float64(cm.maxSessions) * 0.9)
|
|
||||||
if currentCount >= emergencyThreshold {
|
|
||||||
cm.logger.Info("Session storage at %d%% capacity, triggering emergency cleanup",
|
|
||||||
(currentCount*100)/cm.maxSessions)
|
|
||||||
cm.EmergencyCleanup()
|
|
||||||
|
|
||||||
// Recheck after cleanup
|
|
||||||
cm.mutex.RLock()
|
|
||||||
newCount := len(cm.sessionMap)
|
|
||||||
cm.mutex.RUnlock()
|
|
||||||
|
|
||||||
if newCount >= cm.maxSessions {
|
|
||||||
return false, fmt.Errorf("session storage full even after emergency cleanup (%d sessions)", newCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EmergencyCleanup performs aggressive session cleanup when approaching limits
|
|
||||||
func (cm *ChunkManager) EmergencyCleanup() {
|
|
||||||
cm.mutex.Lock()
|
|
||||||
defer cm.mutex.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
removed := 0
|
|
||||||
|
|
||||||
// Remove any expired sessions first
|
|
||||||
expiredKeys := make([]string, 0)
|
|
||||||
for key, entry := range cm.sessionMap {
|
|
||||||
if now.After(entry.ExpiresAt) || now.Sub(entry.LastUsed) > cm.sessionTTL {
|
|
||||||
expiredKeys = append(expiredKeys, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, key := range expiredKeys {
|
|
||||||
if entry, exists := cm.sessionMap[key]; exists {
|
|
||||||
atomic.AddInt64(&cm.bytesAllocated, -entry.SizeEstimate)
|
|
||||||
}
|
|
||||||
delete(cm.sessionMap, key)
|
|
||||||
removed++
|
|
||||||
}
|
|
||||||
|
|
||||||
// If still over 80% capacity, remove oldest sessions more aggressively
|
|
||||||
targetCapacity := int(float64(cm.maxSessions) * 0.8)
|
|
||||||
if len(cm.sessionMap) > targetCapacity {
|
|
||||||
excessCount := len(cm.sessionMap) - targetCapacity
|
|
||||||
|
|
||||||
// Use efficient partial sort to find oldest sessions
|
|
||||||
keysToRemove := cm.findOldestSessions(excessCount)
|
|
||||||
|
|
||||||
for _, key := range keysToRemove {
|
|
||||||
if entry, exists := cm.sessionMap[key]; exists {
|
|
||||||
atomic.AddInt64(&cm.bytesAllocated, -entry.SizeEstimate)
|
|
||||||
}
|
|
||||||
delete(cm.sessionMap, key)
|
|
||||||
removed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cm.lastCleanup = now
|
|
||||||
cm.logger.Infof("Emergency cleanup completed: removed %d sessions, %d remaining",
|
|
||||||
removed, len(cm.sessionMap))
|
|
||||||
|
|
||||||
// Log memory stats after emergency cleanup (read only, no forced GC)
|
|
||||||
cm.logger.Infof("Sessions after emergency cleanup: %d, Tracked bytes: %d",
|
|
||||||
len(cm.sessionMap), atomic.LoadInt64(&cm.bytesAllocated))
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSessionCount returns the current number of active sessions (for monitoring)
|
|
||||||
func (cm *ChunkManager) GetSessionCount() int {
|
|
||||||
cm.mutex.RLock()
|
|
||||||
defer cm.mutex.RUnlock()
|
|
||||||
return len(cm.sessionMap)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMemoryStats returns memory usage statistics for monitoring
|
|
||||||
func (cm *ChunkManager) GetMemoryStats() map[string]interface{} {
|
|
||||||
cm.mutex.RLock()
|
|
||||||
sessionCount := len(cm.sessionMap)
|
|
||||||
cm.mutex.RUnlock()
|
|
||||||
|
|
||||||
stats := make(map[string]interface{})
|
|
||||||
stats["active_sessions"] = sessionCount
|
|
||||||
stats["max_sessions"] = cm.maxSessions
|
|
||||||
stats["bytes_allocated"] = atomic.LoadInt64(&cm.bytesAllocated)
|
|
||||||
stats["peak_sessions"] = atomic.LoadInt64(&cm.peakSessions)
|
|
||||||
stats["cleanup_count"] = atomic.LoadInt64(&cm.cleanupCount)
|
|
||||||
stats["session_ttl_hours"] = cm.sessionTTL.Hours()
|
|
||||||
|
|
||||||
// Update peak sessions
|
|
||||||
if int64(sessionCount) > atomic.LoadInt64(&cm.peakSessions) {
|
|
||||||
atomic.StoreInt64(&cm.peakSessions, int64(sessionCount))
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/traefikoidc/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A deep link longer than the cap must not be persisted into the cookies.
|
||||||
|
func TestSetIncomingPathCapsLength(t *testing.T) {
|
||||||
|
sm, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("error"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sess, err := sm.GetSession(httptest.NewRequest("GET", "/", nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(sess.returnToPoolSafely)
|
||||||
|
|
||||||
|
sess.SetIncomingPath("/ok?a=1")
|
||||||
|
if got := sess.GetIncomingPath(); got != "/ok?a=1" {
|
||||||
|
t.Fatalf("short path changed: %q", got)
|
||||||
|
}
|
||||||
|
sess.SetIncomingPath("/x?" + strings.Repeat("q", maxIncomingPathLen+1))
|
||||||
|
if got := sess.GetIncomingPath(); got != "" {
|
||||||
|
t.Fatalf("oversized path stored (%d bytes)", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetermineSchemeCaseInsensitive(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{"HTTPS": "https", "Https": "https", "HTTP": "http", "https": "https"} {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set("X-Forwarded-Proto", in)
|
||||||
|
if got := utils.DetermineScheme(r, false); got != want {
|
||||||
|
t.Errorf("X-Forwarded-Proto %q -> %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -659,6 +659,24 @@ func (c *Config) Validate() error {
|
|||||||
return fmt.Errorf("sessionMaxAge is too large; must be at most %d seconds", math.MaxInt64/int64(time.Second))
|
return fmt.Errorf("sessionMaxAge is too large; must be at most %d seconds", math.MaxInt64/int64(time.Second))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bearer durations are int64 seconds * time.Second at startup (main.go); an
|
||||||
|
// unbounded value wraps negative or to zero and disables the age check
|
||||||
|
// and failure throttle.
|
||||||
|
for name, v := range map[string]int64{
|
||||||
|
"maxTokenAgeSeconds": c.MaxTokenAgeSeconds,
|
||||||
|
"bearerFailureWindowSeconds": int64(c.BearerFailureWindowSeconds),
|
||||||
|
"bearerFailurePenaltySeconds": int64(c.BearerFailurePenaltySeconds),
|
||||||
|
} {
|
||||||
|
if v > maxDurationSeconds {
|
||||||
|
return fmt.Errorf("%s is too large; must be at most %d seconds", name, maxDurationSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The throttle sweep cutoff adds window and penalty.
|
||||||
|
if int64(c.BearerFailureWindowSeconds) > 0 && int64(c.BearerFailurePenaltySeconds) > 0 &&
|
||||||
|
int64(c.BearerFailureWindowSeconds)+int64(c.BearerFailurePenaltySeconds) > maxDurationSeconds {
|
||||||
|
return fmt.Errorf("bearerFailureWindowSeconds plus bearerFailurePenaltySeconds is too large")
|
||||||
|
}
|
||||||
|
|
||||||
// Validate audience if specified
|
// Validate audience if specified
|
||||||
if c.Audience != "" {
|
if c.Audience != "" {
|
||||||
// Validate audience format - should be a valid identifier or URL
|
// Validate audience format - should be a valid identifier or URL
|
||||||
@@ -954,6 +972,7 @@ func isValidSecureURL(s string) bool {
|
|||||||
// isLoopbackHost reports whether host is "localhost" or a loopback IP literal
|
// isLoopbackHost reports whether host is "localhost" or a loopback IP literal
|
||||||
// (127.0.0.0/8 or ::1).
|
// (127.0.0.0/8 or ::1).
|
||||||
func isLoopbackHost(host string) bool {
|
func isLoopbackHost(host string) bool {
|
||||||
|
host = canonicalHost(host)
|
||||||
if strings.EqualFold(host, "localhost") {
|
if strings.EqualFold(host, "localhost") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1227,6 +1246,14 @@ func (c *Config) GetSecurityHeadersApplier() func(http.ResponseWriter, *http.Req
|
|||||||
}
|
}
|
||||||
|
|
||||||
// isOriginAllowed checks if an origin is in the allowed list
|
// isOriginAllowed checks if an origin is in the allowed list
|
||||||
|
const (
|
||||||
|
// maxDurationSeconds is the largest whole-second value whose
|
||||||
|
// time.Duration product with time.Second does not overflow int64.
|
||||||
|
maxDurationSeconds = math.MaxInt64 / int64(time.Second)
|
||||||
|
// maxRedisPoolSize bounds the eagerly allocated connection channel.
|
||||||
|
maxRedisPoolSize = 10000
|
||||||
|
)
|
||||||
|
|
||||||
// Validate checks if the Redis configuration is valid
|
// Validate checks if the Redis configuration is valid
|
||||||
func (rc *RedisConfig) Validate() error {
|
func (rc *RedisConfig) Validate() error {
|
||||||
if !rc.Enabled {
|
if !rc.Enabled {
|
||||||
@@ -1253,6 +1280,9 @@ func (rc *RedisConfig) Validate() error {
|
|||||||
if rc.PoolSize < 0 {
|
if rc.PoolSize < 0 {
|
||||||
return fmt.Errorf("pool size cannot be negative")
|
return fmt.Errorf("pool size cannot be negative")
|
||||||
}
|
}
|
||||||
|
if rc.PoolSize > maxRedisPoolSize {
|
||||||
|
return fmt.Errorf("pool size is too large; must be at most %d", maxRedisPoolSize)
|
||||||
|
}
|
||||||
if rc.ConnectTimeout < 0 {
|
if rc.ConnectTimeout < 0 {
|
||||||
return fmt.Errorf("connect timeout cannot be negative")
|
return fmt.Errorf("connect timeout cannot be negative")
|
||||||
}
|
}
|
||||||
@@ -1286,6 +1316,21 @@ func (rc *RedisConfig) Validate() error {
|
|||||||
return fmt.Errorf("health check interval cannot be negative")
|
return fmt.Errorf("health check interval cannot be negative")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seconds become time.Duration (x * time.Second) and the threshold is
|
||||||
|
// narrowed to int32 in the breaker; out-of-range values wrap, which
|
||||||
|
// panics time.NewTicker or trips the breaker on the first failure.
|
||||||
|
for name, v := range map[string]int{
|
||||||
|
"health check interval": rc.HealthCheckInterval,
|
||||||
|
"circuit breaker timeout": rc.CircuitBreakerTimeout,
|
||||||
|
} {
|
||||||
|
if int64(v) > maxDurationSeconds {
|
||||||
|
return fmt.Errorf("%s is too large; must be at most %d seconds", name, maxDurationSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rc.CircuitBreakerThreshold > math.MaxInt32 {
|
||||||
|
return fmt.Errorf("circuit breaker threshold is too large; must be at most %d", math.MaxInt32)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func boundsTestConfig() *Config {
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.ProviderURL = "https://auth.example.com"
|
||||||
|
cfg.CallbackURL = "/callback"
|
||||||
|
cfg.ClientID = "test-client"
|
||||||
|
cfg.ClientSecret = "test-client-secret"
|
||||||
|
cfg.SessionEncryptionKey = "test-encryption-key-32-bytes-long!!"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWholeSeconds = math.MaxInt64 / int64(time.Second)
|
||||||
|
|
||||||
|
func TestConfigValidateRejectsDurationOverflow(t *testing.T) {
|
||||||
|
cases := map[string]func(c *Config){
|
||||||
|
"maxTokenAgeSeconds wraps negative": func(c *Config) { c.MaxTokenAgeSeconds = maxWholeSeconds + 1 },
|
||||||
|
"maxTokenAgeSeconds wraps to zero": func(c *Config) { c.MaxTokenAgeSeconds = 1 << 55 },
|
||||||
|
"bearerFailureWindowSeconds": func(c *Config) { c.BearerFailureWindowSeconds = int(maxWholeSeconds + 1) },
|
||||||
|
"bearerFailurePenaltySeconds": func(c *Config) { c.BearerFailurePenaltySeconds = int(maxWholeSeconds + 1) },
|
||||||
|
}
|
||||||
|
for name, mutate := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
cfg := boundsTestConfig()
|
||||||
|
mutate(cfg)
|
||||||
|
if err := cfg.Validate(); err == nil {
|
||||||
|
t.Fatal("Validate accepted an overflowing duration")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("max exact value still valid", func(t *testing.T) {
|
||||||
|
cfg := boundsTestConfig()
|
||||||
|
cfg.MaxTokenAgeSeconds = maxWholeSeconds
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Fatalf("boundary value rejected: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedisConfigValidateRejectsOverflowAndHugePool(t *testing.T) {
|
||||||
|
base := func() *RedisConfig {
|
||||||
|
rc := &RedisConfig{Enabled: true, Address: "localhost:6379"}
|
||||||
|
return rc
|
||||||
|
}
|
||||||
|
cases := map[string]func(rc *RedisConfig){
|
||||||
|
"healthCheckInterval wraps to zero": func(rc *RedisConfig) { rc.HealthCheckInterval = 1 << 55 },
|
||||||
|
"circuitBreakerTimeout wraps": func(rc *RedisConfig) { rc.CircuitBreakerTimeout = int(maxWholeSeconds + 1) },
|
||||||
|
"circuitBreakerThreshold narrows": func(rc *RedisConfig) { rc.CircuitBreakerThreshold = math.MaxInt32 + 1 },
|
||||||
|
"poolSize absurd": func(rc *RedisConfig) { rc.PoolSize = 1_000_000_000 },
|
||||||
|
}
|
||||||
|
for name, mutate := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
rc := base()
|
||||||
|
mutate(rc)
|
||||||
|
if err := rc.Validate(); err == nil {
|
||||||
|
t.Fatal("Validate accepted an unsafe value")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("defaults and sane values stay valid", func(t *testing.T) {
|
||||||
|
rc := base()
|
||||||
|
rc.PoolSize, rc.CircuitBreakerThreshold, rc.HealthCheckInterval = 50, 5, 30
|
||||||
|
if err := rc.Validate(); err != nil {
|
||||||
|
t.Fatalf("sane config rejected: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newNSCache(t *testing.T) *UniversalCache {
|
||||||
|
t.Helper()
|
||||||
|
c := NewUniversalCache(UniversalCacheConfig{Type: CacheTypeToken, MaxSize: 100, DefaultTTL: time.Hour, CleanupInterval: time.Hour})
|
||||||
|
t.Cleanup(func() { _ = c.Close() })
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// A verdict stored by one middleware instance must not satisfy another
|
||||||
|
// instance that verifies against a different client or audience.
|
||||||
|
func TestTokenCacheIsolatedPerInstanceNamespace(t *testing.T) {
|
||||||
|
shared := newNSCache(t)
|
||||||
|
a := &TokenCache{cache: shared}
|
||||||
|
b := &TokenCache{cache: shared}
|
||||||
|
a.SetNamespace("https://idp|app-a|app-a")
|
||||||
|
b.SetNamespace("https://idp|app-b|app-b")
|
||||||
|
|
||||||
|
a.Set("tok", map[string]interface{}{"sub": "u"}, time.Minute)
|
||||||
|
if _, ok := b.Get("tok"); ok {
|
||||||
|
t.Fatal("instance B hit a verdict cached by instance A")
|
||||||
|
}
|
||||||
|
if _, ok := a.Get("tok"); !ok {
|
||||||
|
t.Fatal("instance A lost its own verdict")
|
||||||
|
}
|
||||||
|
a.Delete("tok")
|
||||||
|
if _, ok := a.Get("tok"); ok {
|
||||||
|
t.Fatal("Delete did not remove the namespaced entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenCacheNamespaceFollowsDCR(t *testing.T) {
|
||||||
|
shared := newNSCache(t)
|
||||||
|
tc := &TokenCache{cache: shared}
|
||||||
|
tc.SetNamespace("p|old|old")
|
||||||
|
tc.Set("tok", map[string]interface{}{"sub": "u"}, time.Minute)
|
||||||
|
tc.SetNamespace("p|new|new")
|
||||||
|
if _, ok := tc.Get("tok"); ok {
|
||||||
|
t.Fatal("verdict cached under the old client survived a namespace change")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenCacheEmptyNamespaceKeepsLegacyKey(t *testing.T) {
|
||||||
|
shared := newNSCache(t)
|
||||||
|
a := &TokenCache{cache: shared}
|
||||||
|
b := &TokenCache{cache: shared}
|
||||||
|
a.Set("tok", map[string]interface{}{"sub": "u"}, time.Minute)
|
||||||
|
if _, ok := b.Get("tok"); !ok {
|
||||||
|
t.Fatal("namespace-less caches must keep sharing the legacy key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntrospectionKeyScopedPerInstance(t *testing.T) {
|
||||||
|
a := &TraefikOidc{tokenCache: &TokenCache{}}
|
||||||
|
b := &TraefikOidc{tokenCache: &TokenCache{}}
|
||||||
|
a.tokenCache.SetNamespace("p|app-a|app-a")
|
||||||
|
b.tokenCache.SetNamespace("p|app-b|app-b")
|
||||||
|
if a.introspectionKey("tok") == b.introspectionKey("tok") {
|
||||||
|
t.Fatal("different instances share an introspection cache key")
|
||||||
|
}
|
||||||
|
if got := (&TraefikOidc{}).introspectionKey("tok"); got != "tok" {
|
||||||
|
t.Fatalf("namespace-less key = %q, want raw token", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-2
@@ -53,7 +53,7 @@ type IntrospectionResponse struct {
|
|||||||
func (t *TraefikOidc) introspectToken(token string) (*IntrospectionResponse, error) {
|
func (t *TraefikOidc) introspectToken(token string) (*IntrospectionResponse, error) {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
if t.introspectionCache != nil {
|
if t.introspectionCache != nil {
|
||||||
if cached, found := t.introspectionCache.Get(token); found {
|
if cached, found := t.introspectionCache.Get(t.introspectionKey(token)); found {
|
||||||
if response, ok := cached.(*IntrospectionResponse); ok {
|
if response, ok := cached.(*IntrospectionResponse); ok {
|
||||||
t.logger.Debugf("Using cached introspection result for token")
|
t.logger.Debugf("Using cached introspection result for token")
|
||||||
return response, nil
|
return response, nil
|
||||||
@@ -173,7 +173,7 @@ func (t *TraefikOidc) introspectToken(token string) (*IntrospectionResponse, err
|
|||||||
cacheDuration = untilExp
|
cacheDuration = untilExp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.introspectionCache.Set(token, &introspectionResp, cacheDuration)
|
t.introspectionCache.Set(t.introspectionKey(token), &introspectionResp, cacheDuration)
|
||||||
t.logger.Debugf("Cached introspection result for %v", cacheDuration)
|
t.logger.Debugf("Cached introspection result for %v", cacheDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,3 +280,12 @@ func (t *TraefikOidc) validateOpaqueToken(token string) error {
|
|||||||
t.logger.Debugf("Opaque token validation successful via introspection")
|
t.logger.Debugf("Opaque token validation successful via introspection")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// introspectionKey scopes an introspection verdict to this instance's
|
||||||
|
// provider, client and audience, like the verified-token cache.
|
||||||
|
func (t *TraefikOidc) introspectionKey(token string) string {
|
||||||
|
if ns := t.tokenCache.Namespace(); ns != "" {
|
||||||
|
return ns + "|" + token
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -812,7 +812,7 @@ func (t *TraefikOidc) RevokeToken(token string) {
|
|||||||
// from the introspection cache after revocation. Next request
|
// from the introspection cache after revocation. Next request
|
||||||
// re-introspects and sees active=false.
|
// re-introspects and sees active=false.
|
||||||
if t.introspectionCache != nil {
|
if t.introspectionCache != nil {
|
||||||
t.introspectionCache.Delete(token)
|
t.introspectionCache.Delete(t.introspectionKey(token))
|
||||||
}
|
}
|
||||||
|
|
||||||
d := blacklistDuration(token)
|
d := blacklistDuration(token)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ package traefikoidc
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -266,8 +267,7 @@ func (t *TraefikOidc) validateStandardTokensRS(rs *requestState) (bool, bool, bo
|
|||||||
var accessVerifyErr error
|
var accessVerifyErr error
|
||||||
if err := t.verifyToken(rs.accessToken); err != nil {
|
if err := t.verifyToken(rs.accessToken); err != nil {
|
||||||
accessVerifyErr = err
|
accessVerifyErr = err
|
||||||
errMsg := err.Error()
|
if errors.Is(err, errInvalidAudience) {
|
||||||
if strings.Contains(errMsg, "invalid audience") || strings.Contains(errMsg, "audience") {
|
|
||||||
if t.strictAudienceValidation {
|
if t.strictAudienceValidation {
|
||||||
if rs.refreshToken != "" {
|
if rs.refreshToken != "" {
|
||||||
return false, true, false
|
return false, true, false
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ type TraefikOidc struct {
|
|||||||
// recovery attempt. Stored atomically so the hot ServeHTTP path can
|
// recovery attempt. Stored atomically so the hot ServeHTTP path can
|
||||||
// throttle retries without acquiring metadataRetryMutex on every request.
|
// throttle retries without acquiring metadataRetryMutex on every request.
|
||||||
lastMetadataRetryNano int64
|
lastMetadataRetryNano int64
|
||||||
|
// initWaiters counts requests parked on initComplete; bounded by
|
||||||
|
// maxInitWaiters so a boot-time flood cannot hold unbounded connections.
|
||||||
|
initWaiters int32
|
||||||
// firstRequestStarted is 0 until the very first non-health request fires
|
// firstRequestStarted is 0 until the very first non-health request fires
|
||||||
// the background-task bootstrap; then it flips to 1 via CAS. Replaces the
|
// the background-task bootstrap; then it flips to 1 via CAS. Replaces the
|
||||||
// firstRequestMutex + firstRequestReceived combo which previously took
|
// firstRequestMutex + firstRequestReceived combo which previously took
|
||||||
@@ -224,6 +227,7 @@ type TraefikOidc struct {
|
|||||||
dcrMu sync.Mutex
|
dcrMu sync.Mutex
|
||||||
shutdownOnce sync.Once
|
shutdownOnce sync.Once
|
||||||
sessionInvalidationCache CacheInterface
|
sessionInvalidationCache CacheInterface
|
||||||
|
frontchannelMarkers markerQuota
|
||||||
refreshResultCache CacheInterface
|
refreshResultCache CacheInterface
|
||||||
minimalHeaders bool
|
minimalHeaders bool
|
||||||
stripAuthCookies bool
|
stripAuthCookies bool
|
||||||
|
|||||||
+38
-2
@@ -380,6 +380,10 @@ func (c *UniversalCache) setLocalLocked(key string, value interface{}, ttl time.
|
|||||||
growth = size - existing.Size
|
growth = size - existing.Size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.rejectOversized(key, size) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check memory limits
|
// Check memory limits
|
||||||
if c.config.MaxMemoryBytes > 0 {
|
if c.config.MaxMemoryBytes > 0 {
|
||||||
// Evict items if necessary to make room
|
// Evict items if necessary to make room
|
||||||
@@ -712,6 +716,9 @@ func (c *UniversalCache) getLocal(key string) (interface{}, bool) {
|
|||||||
if gracePeriod, ok := item.Metadata["grace_period_active"].(bool); ok && gracePeriod {
|
if gracePeriod, ok := item.Metadata["grace_period_active"].(bool); ok && gracePeriod {
|
||||||
if c.shouldExtendGracePeriod(item, now) {
|
if c.shouldExtendGracePeriod(item, now) {
|
||||||
newExpiry := c.calculateNewExpiry(item, now)
|
newExpiry := c.calculateNewExpiry(item, now)
|
||||||
|
if _, set := item.Metadata[graceOriginKey]; !set {
|
||||||
|
item.Metadata[graceOriginKey] = item.ExpiresAt
|
||||||
|
}
|
||||||
item.ExpiresAt = newExpiry
|
item.ExpiresAt = newExpiry
|
||||||
c.logger.Infof("UniversalCache[%s]: Extended grace period for key=%s until %v",
|
c.logger.Infof("UniversalCache[%s]: Extended grace period for key=%s until %v",
|
||||||
c.config.Type, key, newExpiry)
|
c.config.Type, key, newExpiry)
|
||||||
@@ -915,11 +922,29 @@ func (c *UniversalCache) evictOldest() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rejectOversized drops a value larger than the whole memory cap instead of
|
||||||
|
// flushing every other entry to fit it. A stale copy under the key is removed.
|
||||||
|
// Caller holds c.mu.
|
||||||
|
func (c *UniversalCache) rejectOversized(key string, size int64) bool {
|
||||||
|
if c.config.MaxMemoryBytes <= 0 || size <= c.config.MaxMemoryBytes {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if existing, ok := c.items[key]; ok {
|
||||||
|
c.removeItem(key, existing)
|
||||||
|
}
|
||||||
|
c.logger.Infof("UniversalCache[%s]: dropped oversized value key=%s size=%d cap=%d",
|
||||||
|
c.config.Type, key, size, c.config.MaxMemoryBytes)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// SetMaxSize sets the maximum size and evicts items if necessary
|
// SetMaxSize sets the maximum size and evicts items if necessary
|
||||||
func (c *UniversalCache) SetMaxSize(newSize int) {
|
func (c *UniversalCache) SetMaxSize(newSize int) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if newSize < 1 {
|
||||||
|
newSize = 1 // a non-positive bound would spin the eviction loop forever
|
||||||
|
}
|
||||||
oldSize := c.config.MaxSize
|
oldSize := c.config.MaxSize
|
||||||
c.config.MaxSize = newSize
|
c.config.MaxSize = newSize
|
||||||
|
|
||||||
@@ -1021,6 +1046,9 @@ func (c *UniversalCache) estimateSize(value interface{}) int64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// graceOriginKey holds the item's first expiry once grace has started.
|
||||||
|
const graceOriginKey = "grace_origin"
|
||||||
|
|
||||||
// shouldExtendGracePeriod determines if grace period should be extended
|
// shouldExtendGracePeriod determines if grace period should be extended
|
||||||
func (c *UniversalCache) shouldExtendGracePeriod(item *CacheItem, now time.Time) bool {
|
func (c *UniversalCache) shouldExtendGracePeriod(item *CacheItem, now time.Time) bool {
|
||||||
if c.config.MetadataConfig == nil {
|
if c.config.MetadataConfig == nil {
|
||||||
@@ -1040,8 +1068,12 @@ func (c *UniversalCache) shouldExtendGracePeriod(item *CacheItem, now time.Time)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate how long since the item originally expired
|
// Measure from the first expiry: later extensions overwrite ExpiresAt.
|
||||||
timeSinceExpiry := now.Sub(item.ExpiresAt)
|
origin := item.ExpiresAt
|
||||||
|
if o, ok := item.Metadata[graceOriginKey].(time.Time); ok {
|
||||||
|
origin = o
|
||||||
|
}
|
||||||
|
timeSinceExpiry := now.Sub(origin)
|
||||||
return timeSinceExpiry <= maxGrace
|
return timeSinceExpiry <= maxGrace
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1523,6 +1555,10 @@ func (c *UniversalCache) updateLocalCache(key string, value interface{}, ttl tim
|
|||||||
growth = size - existing.Size
|
growth = size - existing.Size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.rejectOversized(key, size) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Check memory limits
|
// Check memory limits
|
||||||
if c.config.MaxMemoryBytes > 0 {
|
if c.config.MaxMemoryBytes > 0 {
|
||||||
for c.currentMemory+growth > c.config.MaxMemoryBytes && c.lruList.Len() > 0 {
|
for c.currentMemory+growth > c.config.MaxMemoryBytes && c.lruList.Len() > 0 {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newBoundsCache(t *testing.T, cfg UniversalCacheConfig) *UniversalCache {
|
||||||
|
t.Helper()
|
||||||
|
cfg.CleanupInterval = time.Hour
|
||||||
|
if cfg.DefaultTTL == 0 {
|
||||||
|
cfg.DefaultTTL = time.Hour
|
||||||
|
}
|
||||||
|
c := NewUniversalCache(cfg)
|
||||||
|
t.Cleanup(func() { _ = c.Close() })
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grace extensions must stay within MaxGracePeriod of the ORIGINAL expiry.
|
||||||
|
func TestGraceNeverServesPastOriginalExpiryBound(t *testing.T) {
|
||||||
|
c := newBoundsCache(t, UniversalCacheConfig{
|
||||||
|
Type: CacheTypeMetadata, MaxSize: 10,
|
||||||
|
MetadataConfig: &MetadataCacheConfig{
|
||||||
|
GracePeriod: 20 * time.Millisecond, ExtendedGracePeriod: 20 * time.Millisecond,
|
||||||
|
MaxGracePeriod: 100 * time.Millisecond, SecurityCriticalMaxGracePeriod: 100 * time.Millisecond,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err := c.Set("k", "v", 10*time.Millisecond); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c.ActivateGracePeriod("k")
|
||||||
|
deadline := time.Now().Add(600 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if _, ok := c.Get("k"); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("stale entry still served 600ms after a 100ms max grace")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetMaxSizeNegativeTerminates(t *testing.T) {
|
||||||
|
c := newBoundsCache(t, UniversalCacheConfig{Type: CacheTypeGeneral, MaxSize: 10})
|
||||||
|
for _, k := range []string{"a", "b", "c"} {
|
||||||
|
_ = c.Set(k, "v", time.Hour)
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { c.SetMaxSize(-1); close(done) }()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("SetMaxSize(-1) did not return")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One oversized value must not flush the other live entries.
|
||||||
|
func TestOversizedValueDoesNotFlushCache(t *testing.T) {
|
||||||
|
c := newBoundsCache(t, UniversalCacheConfig{
|
||||||
|
Type: CacheTypeGeneral, MaxSize: 10, MaxMemoryBytes: 1000, EnableMemoryLimit: true,
|
||||||
|
})
|
||||||
|
_ = c.Set("live", "x", time.Hour)
|
||||||
|
_ = c.Set("big", make([]byte, 5000), time.Hour)
|
||||||
|
if _, ok := c.Get("live"); !ok {
|
||||||
|
t.Fatal("oversized insert evicted a live entry")
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
mem := c.currentMemory
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if mem > 1000 {
|
||||||
|
t.Fatalf("currentMemory=%d exceeds cap 1000", mem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Out-of-range or non-finite nbf/iat must be rejected, not wrapped into the past.
|
||||||
|
func TestTimeClaimsRejectOutOfRange(t *testing.T) {
|
||||||
|
for _, v := range []float64{1e19, 1e300, math.Inf(1), math.Inf(-1), math.NaN()} {
|
||||||
|
if verifyNotBefore(v) == nil {
|
||||||
|
t.Errorf("nbf=%v accepted", v)
|
||||||
|
}
|
||||||
|
if verifyIssuedAt(v) == nil {
|
||||||
|
t.Errorf("iat=%v accepted", v)
|
||||||
|
}
|
||||||
|
if verifyExpiration(v) == nil {
|
||||||
|
t.Errorf("exp=%v accepted", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-3
@@ -453,6 +453,15 @@ func sameHost(a, b string) bool {
|
|||||||
return strings.EqualFold(ua.Host, ub.Host)
|
return strings.EqualFold(ua.Host, ub.Host)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// canonicalHost drops the root dot ("localhost." is localhost) and an IPv6
|
||||||
|
// zone id so the blocklists see one spelling per host.
|
||||||
|
func canonicalHost(host string) string {
|
||||||
|
if i := strings.IndexByte(host, '%'); i > 0 && net.ParseIP(host[:i]) != nil {
|
||||||
|
host = host[:i]
|
||||||
|
}
|
||||||
|
return strings.TrimRight(host, ".")
|
||||||
|
}
|
||||||
|
|
||||||
// validateHost validates a hostname or IP address for security.
|
// validateHost validates a hostname or IP address for security.
|
||||||
// It prevents access to localhost, private networks, and known metadata endpoints.
|
// It prevents access to localhost, private networks, and known metadata endpoints.
|
||||||
// When allowPrivateIPAddresses is enabled, private IP checks are skipped.
|
// When allowPrivateIPAddresses is enabled, private IP checks are skipped.
|
||||||
@@ -462,13 +471,14 @@ func sameHost(a, b string) bool {
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - An error if the host is dangerous or not allowed, nil if safe.
|
// - An error if the host is dangerous or not allowed, nil if safe.
|
||||||
func (t *TraefikOidc) validateHost(host string) error {
|
func (t *TraefikOidc) validateHost(host string) error {
|
||||||
hostname := host
|
hostname := canonicalHost(host)
|
||||||
if strings.Contains(host, ":") {
|
if net.ParseIP(hostname) == nil && strings.Contains(hostname, ":") {
|
||||||
var err error
|
var err error
|
||||||
hostname, _, err = net.SplitHostPort(host)
|
hostname, _, err = net.SplitHostPort(hostname)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid host format: %w", err)
|
return fmt.Errorf("invalid host format: %w", err)
|
||||||
}
|
}
|
||||||
|
hostname = canonicalHost(hostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
ip := net.ParseIP(hostname)
|
ip := net.ParseIP(hostname)
|
||||||
|
|||||||
+1
-1
@@ -14,4 +14,4 @@ const devPluginVersion = "0.0.0-dev"
|
|||||||
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
|
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
|
||||||
// not the interpreted plugin). This build-stamped constant is therefore the
|
// not the interpreted plugin). This build-stamped constant is therefore the
|
||||||
// single source of truth for the version reported by anonymous usage telemetry.
|
// single source of truth for the version reported by anonymous usage telemetry.
|
||||||
const traefikoidcPluginVersion = "1.0.44"
|
const traefikoidcPluginVersion = "0.0.0-dev"
|
||||||
|
|||||||
Reference in New Issue
Block a user