Compare commits

..
1 Commits
Author SHA1 Message Date
github-actions[bot] a533ae2ef7 chore(release): v1.0.42 2026-09-29 11:02:23 +00:00
50 changed files with 128 additions and 1972 deletions
+52
View File
@@ -0,0 +1,52 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
open-pull-requests-limit: 5
commit-message:
prefix: "chore(deps)"
include: "scope"
labels:
- "dependencies"
- "github-actions"
reviewers:
- "lukaszraczylo"
# Maintain Go module dependencies
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
open-pull-requests-limit: 10
commit-message:
prefix: "chore(deps)"
include: "scope"
labels:
- "dependencies"
- "go"
reviewers:
- "lukaszraczylo"
# Group patch updates together
groups:
patch-updates:
patterns:
- "*"
update-types:
- "patch"
minor-updates:
patterns:
- "*"
update-types:
- "minor"
# Ignore certain dependencies if needed
ignore:
# Example: ignore specific versions
# - dependency-name: "github.com/example/package"
# versions: ["1.x", "2.x"]
-7
View File
@@ -72,13 +72,6 @@ testData:
value: "{{.Claims.email}}"
- name: Authorization
value: "Bearer {{.AccessToken}}"
# Object/array claim as one JSON header; the claim must be whitelisted.
- name: X-Tenant
value: '{{get .Claims "tenant" | toJson}}'
# Extends the built-in claim whitelist for templated headers.
allowedClaims:
- tenant
# Security headers (default profile is enabled out of the box)
securityHeaders:
+2 -28
View File
@@ -441,34 +441,8 @@ headers:
Rendering the whole context (`{{.}}`, `{{$}}`), the whole claims map
(`{{.Claims}}`), or any non-listed claim is rejected — this prevents a template
from accidentally forwarding raw tokens or unlisted claims. `range`/`with` must
target a specific listed claim (e.g. `{{range .Claims.groups}}`); `get`, `default`
and `toJson` are the only functions allowed.
### Forwarding an object claim as JSON
`toJson` serializes a claim (object, array or scalar) to compact JSON, so a
backend can parse the whole claim itself instead of the middleware flattening
it into one header per key:
```yaml
allowedClaims:
- tenant
headers:
- name: X-Tenant
value: '{{get .Claims "tenant" | toJson}}' # {"id":"t-1","plan":"pro"}
- name: X-Groups
value: "{{toJson .Claims.groups}}" # ["admin","users"]
```
- The claim must be on the whitelist (built-in or `allowedClaims`).
`{{toJson .Claims}}`, `{{toJson .}}` and non-listed claims are rejected.
- An absent or empty claim skips the header instead of sending `""`.
- Control characters are JSON-escaped, so a claim cannot inject extra header
lines.
- A rendered value over 8192 bytes, or one with bidi-override characters, is
dropped. The oversize case is logged at error level (header name only, never
the value); keep the claim small, since proxies commonly cap headers at
8-16 KB.
target a specific listed claim (e.g. `{{range .Claims.groups}}`); `get`/`default`
are the only functions allowed.
> **File-provider users: escape the braces.** Traefik's file provider runs
> every dynamic configuration file through Go templating before the plugin
-24
View File
@@ -1,24 +0,0 @@
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)
}
}
}
+3 -12
View File
@@ -223,9 +223,6 @@ func headerInjectionRuneReason(r rune) string {
return ""
}
// headerReasonTooLong is the headerValueReason result for an over-long value.
const headerReasonTooLong = "exceeds max length"
// headerValueReason reports why value is unsafe to forward as a free-form HTTP
// header value, or "" if acceptable. It rejects values over maxLen (maxLen<=0
// disables the check) and values containing control or bidi-override runes, but
@@ -233,7 +230,7 @@ const headerReasonTooLong = "exceeds max length"
// never includes the value, so it is safe to log.
func headerValueReason(value string, maxLen int) string {
if maxLen > 0 && len(value) > maxLen {
return headerReasonTooLong
return "exceeds max length"
}
for _, r := range value {
if reason := headerInjectionRuneReason(r); reason != "" {
@@ -371,11 +368,6 @@ func enforceIatAge(claims map[string]interface{}, maxAge time.Duration) *bearerE
if time.Since(iat) > maxAge {
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
}
@@ -558,10 +550,9 @@ func clientIPForBearer(req *http.Request) string {
}
host, _, err := net.SplitHostPort(req.RemoteAddr)
if err != nil {
host = req.RemoteAddr
return req.RemoteAddr
}
// One IPv6 /64 shares a key, so a single host cannot mint 2^64 entries.
return limiterKey(host)
return host
}
// --- Bearer auth entrypoint ---
-24
View File
@@ -1,24 +0,0 @@
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)
}
}
-94
View File
@@ -139,7 +139,6 @@ func main() {
runCheck("middleware-erraborthandler-aborts-under-yaegi", checkErrAbortHandlerAbortsUnderYaegi)
runCheck("fix17-setifabsent-claims-once", checkSetIfAbsentUnderYaegi)
runCheck("issue162-websocket-forwards-header-templates", checkIssue162WebSocketHeaderTemplates)
runCheck("tojson-header-template", checkToJsonHeaderTemplate)
runCheck("streaming-refresh-revoked-grant-rejected", checkStreamingRefreshRevokedGrant)
fmt.Println("OK: all yaegi regression checks passed")
}
@@ -808,99 +807,6 @@ func checkIssue162WebSocketHeaderTemplates() (string, error) {
return "", nil
}
// checkToJsonHeaderTemplate pins the toJson template helper under the
// interpreter: its (string, error) closure, json.Marshal of an object claim,
// the nil/absent-claim skip and the validator's rejection of a whole-claims dump.
func checkToJsonHeaderTemplate() (string, error) {
discovery := servers.NewOIDCServer(nil)
defer discovery.Close()
cfg := oidc.CreateConfig()
cfg.ProviderURL = discovery.URL
cfg.ClientID = "yaegi-check-tojson-client"
cfg.ClientSecret = "yaegi-check-tojson-secret"
cfg.CallbackURL = "/oauth2/callback"
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef"
cfg.AllowedClaims = []string{"tenant"}
cfg.Headers = []oidc.TemplatedHeader{
{Name: "X-Tenant", Value: `{{ get .Claims "tenant" | toJson }}`},
{Name: "X-Groups", Value: "{{ toJson .Claims.groups }}"},
{Name: "X-Missing", Value: "{{ toJson .Claims.department }}"},
}
if err := cfg.Validate(); err != nil {
return "", fmt.Errorf("toJson headers must validate: %w", err)
}
bad := oidc.CreateConfig()
*bad = *cfg
bad.Headers = []oidc.TemplatedHeader{{Name: "X-All", Value: "{{ toJson .Claims }}"}}
if err := bad.Validate(); err == nil {
return "", fmt.Errorf("toJson of the whole claims map must fail validation")
}
var seen http.Header
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.Header.Clone()
})
h, err := oidc.New(context.Background(), next, cfg, "yaegi-check-tojson")
if err != nil {
return "", fmt.Errorf("New: %w", err)
}
if closer, ok := h.(interface{ Close() error }); ok {
defer closer.Close()
}
sm, err := oidc.NewSessionManager(cfg.SessionEncryptionKey, cfg.ForceHTTPS, cfg.CookieDomain, cfg.CookiePrefix, time.Duration(cfg.SessionMaxAge)*time.Second, oidc.NewLogger("error"))
if err != nil {
return "", fmt.Errorf("NewSessionManager: %w", err)
}
defer sm.Shutdown()
setupReq := httptest.NewRequest("GET", "/ws", nil)
session, err := sm.GetSession(setupReq)
if err != nil {
return "", fmt.Errorf("GetSession: %w", err)
}
if err := session.SetAuthenticated(true); err != nil {
return "", fmt.Errorf("SetAuthenticated: %w", err)
}
session.SetUserIdentifier("[email protected]")
b64 := base64.RawURLEncoding.EncodeToString
sig := make([]byte, 32)
for i := range sig {
sig[i] = byte(i + 1)
}
session.SetIDToken(b64([]byte(`{"alg":"RS256","typ":"JWT"}`)) + "." + b64([]byte(`{"tenant":{"id":"t-1","plan":"pro"},"groups":["a","b"]}`)) + "." + b64(sig))
rec := httptest.NewRecorder()
if err := session.Save(setupReq, rec); err != nil {
return "", fmt.Errorf("session.Save: %w", err)
}
session.ReturnToPool()
req := httptest.NewRequest("GET", "/ws", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
rw := httptest.NewRecorder()
h.ServeHTTP(rw, req)
if seen == nil {
return "", fmt.Errorf("next was not reached (status %d)", rw.Code)
}
if got, want := seen.Get("X-Tenant"), `{"id":"t-1","plan":"pro"}`; got != want {
return "", fmt.Errorf("X-Tenant = %q, want %q", got, want)
}
if got, want := seen.Get("X-Groups"), `["a","b"]`; got != want {
return "", fmt.Errorf("X-Groups = %q, want %q", got, want)
}
if got := seen.Get("X-Missing"); got != "" {
return "", fmt.Errorf("X-Missing = %q, want header skipped", got)
}
return "", nil
}
// checkStreamingRefreshRevokedGrant drives streamingRefresh under the
// interpreter through the real RefreshCoordinator and token exchanger: an
// expired session whose refresh the IdP answers with invalid_grant must get
+1 -16
View File
@@ -571,7 +571,7 @@ be emitted — the standard OIDC claims plus common provider claims (`email`,
`phone_number`, `email_verified`, `updated_at`; the authoritative list is
`safeClaimsFields` in `template_validation.go`). Rejected: rendering the whole
context (`{{.}}`, `{{$}}`) or the whole claims map (`{{.Claims}}`), any claim not
on the list, functions other than `get`/`default`/`toJson`, and `range`/`with` that do not
on the list, functions other than `get`/`default`, and `range`/`with` that do not
target a specific listed claim (e.g. `{{range .Claims}}` is rejected; `{{range
.Claims.groups}}` is allowed).
@@ -592,21 +592,6 @@ Listed names apply to both direct access (`{{.Claims.employee_id}}`) and
claim (e.g. `realm_access.roles`) are already accessible without listing them
individually.
**`toJson`** forwards an object or array claim as one JSON header:
```yaml
allowedClaims:
- tenant
headers:
- name: "X-Tenant"
value: "{{get .Claims \"tenant\" | toJson}}"
```
It only serializes values the template can already reach, so the whitelist
still applies; `{{toJson .Claims}}` is rejected. Control characters are
JSON-escaped, and values over 8192 bytes or with bidi-override characters are
dropped; an oversize value is logged at error level (header name only).
---
## Security Headers
-54
View File
@@ -1,54 +0,0 @@
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)
}
}
+3 -31
View File
@@ -13,7 +13,6 @@ import (
"net/http/cookiejar"
"net/url"
"strings"
"sync/atomic"
"time"
)
@@ -294,37 +293,10 @@ func extractClaims(tokenString string) (map[string]interface{}, error) {
// TokenCache provides a specialized cache for JWT tokens and their parsed claims.
// It wraps the UniversalCache with token-specific operations.
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 *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.
// It uses the global cache manager to ensure singleton behavior.
func NewTokenCache() *TokenCache {
@@ -341,7 +313,7 @@ func NewTokenCache() *TokenCache {
// - claims: Parsed claims from the token
// - expiration: The duration for which the cache entry should be valid
func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiration time.Duration) {
token = tc.key(token)
token = "t-" + token
_ = tc.cache.Set(token, claims, expiration) // Safe to ignore: cache failures are non-critical
}
@@ -353,7 +325,7 @@ func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiratio
// - map[string]interface{}: The cached claims if found
// - 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) {
token = tc.key(token)
token = "t-" + token
value, found := tc.cache.Get(token)
if !found {
return nil, false
@@ -366,7 +338,7 @@ func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
// Parameters:
// - token: The raw token string to remove from the cache
func (tc *TokenCache) Delete(token string) {
token = tc.key(token)
token = "t-" + token
tc.cache.Delete(token)
}
-25
View File
@@ -1,25 +0,0 @@
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")
}
}
-238
View File
@@ -1,238 +0,0 @@
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)
}
}
}
+18 -20
View File
@@ -120,8 +120,15 @@ func (r *RedisBackend) Set(ctx context.Context, key string, value []byte, ttl ti
// Use PSETEX for millisecond precision, SETEX for second precision
if ttl > 0 {
cmd, arg := ttlCommand(ttl)
_, err = conn.Do(cmd, prefixedKey, arg, string(value))
ttlMillis := ttl.Milliseconds()
if ttlMillis < 1000 {
// 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 {
_, err = conn.Do("SET", prefixedKey, string(value))
}
@@ -617,19 +624,6 @@ func isRetryableError(err error) bool {
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
// 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 {
@@ -664,16 +658,20 @@ func (r *RedisBackend) SetMany(ctx context.Context, items map[string][]byte, ttl
pipeline := conn.NewPipeline()
// Queue all SET commands
var ttlCmd, ttlArg string
if ttl > 0 {
ttlCmd, ttlArg = ttlCommand(ttl)
}
ttlSeconds := int(ttl.Seconds())
ttlMillis := ttl.Milliseconds()
for key, value := range items {
prefixedKey := r.prefixKey(key)
if ttl > 0 {
pipeline.Queue(ttlCmd, prefixedKey, ttlArg, string(value))
if ttlMillis < 1000 {
// 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 {
pipeline.Queue("SET", prefixedKey, string(value))
}
-45
View File
@@ -1,45 +0,0 @@
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)
})
}
+3 -6
View File
@@ -81,7 +81,6 @@ type CircuitBreaker struct {
stateMu sync.RWMutex
timeMu sync.RWMutex
halfOpenRequests atomic.Int32
halfOpenSuccesses atomic.Int32
consecutiveFailures atomic.Int32
state atomic.Int32
}
@@ -171,12 +170,12 @@ func (cb *CircuitBreaker) RecordSuccess() {
case StateHalfOpen:
// If we've had enough successful requests, close the circuit
successfulRequests := cb.halfOpenSuccesses.Add(1)
if int64(successfulRequests) >= int64(cb.config.HalfOpenMaxRequests) {
successfulRequests := cb.halfOpenRequests.Load()
// #nosec G115 -- HalfOpenMaxRequests is a small config value that fits in int32
if successfulRequests >= int32(cb.config.HalfOpenMaxRequests) {
cb.setState(StateClosed)
cb.consecutiveFailures.Store(0)
cb.halfOpenRequests.Store(0)
cb.halfOpenSuccesses.Store(0)
}
}
}
@@ -217,7 +216,6 @@ func (cb *CircuitBreaker) RecordFailure() {
func (cb *CircuitBreaker) openCircuit() {
cb.setState(StateOpen)
cb.halfOpenRequests.Store(0)
cb.halfOpenSuccesses.Store(0)
cb.timeMu.Lock()
cb.nextRetryTime = time.Now().Add(cb.config.Timeout)
@@ -253,7 +251,6 @@ func (cb *CircuitBreaker) Reset() {
cb.totalRequests.Store(0)
cb.totalFailures.Store(0)
cb.halfOpenRequests.Store(0)
cb.halfOpenSuccesses.Store(0)
cb.rejectedRequests.Store(0)
cb.stateTransitions.Store(0)
+3 -4
View File
@@ -168,12 +168,11 @@ func (h *HealthCheckBackend) IsHealthy() bool {
func (h *HealthCheckBackend) recordResult(success bool) {
// #nosec G115 -- threshold config values are small integers that fit in int32
if success {
h.consecutiveFails.Store(0)
fails := h.consecutiveFails.Swap(0)
oks := h.consecutiveOK.Add(1)
// Promote on a success streak. Keyed on the status, not on the fail
// counter, which the first success of the streak already zeroed.
if HealthStatus(h.status.Load()) != HealthHealthy && oks >= int32(h.config.HealthyThreshold) {
// Check if we should transition to healthy
if fails > 0 && oks >= int32(h.config.HealthyThreshold) {
oldStatus := HealthStatus(h.status.Swap(int32(HealthHealthy)))
if oldStatus != HealthHealthy && h.config.OnStatusChange != nil {
h.config.OnStatusChange(oldStatus, HealthHealthy)
-56
View File
@@ -1,56 +0,0 @@
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())
}
}
+1 -9
View File
@@ -93,16 +93,8 @@ func (cb *CircuitBreaker) ExecuteWithContext(ctx context.Context, fn func() erro
return fmt.Errorf("circuit breaker is open")
}
// 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()
}
}()
// Execute the function
err := fn()
panicked = false
if err != nil {
cb.recordFailure()
@@ -1,40 +0,0 @@
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())
}
}
+1 -1
View File
@@ -142,7 +142,7 @@ func DetermineScheme(req *http.Request, forceHTTPS bool) string {
// "X-Forwarded-Proto: ftp") would otherwise be emitted verbatim into
// redirect_uri as a broken or weakened scheme. Invalid values fall
// through to the TLS check (R103).
if scheme := strings.ToLower(req.Header.Get("X-Forwarded-Proto")); scheme == "https" || scheme == "http" {
if scheme := req.Header.Get("X-Forwarded-Proto"); scheme == "https" || scheme == "http" {
return scheme
}
-7
View File
@@ -389,13 +389,6 @@ func (c *JWKCache) getPublicKeyFresh(ctx context.Context, jwksURL, kid string, h
if withinCooldown {
// 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 jwks, ok := cached.(*JWKSet); ok {
if k, ok := buildParsedJWKS(jwks).keys[kid]; ok {
+3 -26
View File
@@ -6,9 +6,7 @@ import (
"crypto/ecdsa"
"crypto/rsa"
"encoding/base64"
"errors"
"fmt"
"math"
"math/big"
"strings"
"time"
@@ -196,16 +194,11 @@ func (j *JWT) Verify(issuerURL, expectedAudience string) error {
//
// Returns:
// - 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 {
switch aud := tokenAudience.(type) {
case string:
if aud != expectedAudience {
return errInvalidAudience
return fmt.Errorf("invalid audience")
}
case []interface{}:
found := false
@@ -216,7 +209,7 @@ func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
}
}
if !found {
return errInvalidAudience
return fmt.Errorf("invalid audience")
}
default:
return fmt.Errorf("invalid 'aud' claim type")
@@ -238,9 +231,6 @@ func verifyIssuer(tokenIssuer, expectedIssuer string) error {
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.
// It handles both future constraints (exp) and past constraints (iat, nbf).
// Parameters:
@@ -251,10 +241,6 @@ const maxClaimUnix = 253402300799
// Returns:
// - An error describing the failure (e.g., "token has expired", "token used before issued")
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)
now := time.Now()
@@ -328,9 +314,6 @@ func verifyTimeClaims(claims map[string]interface{}) error {
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
// public key, skipping the PEM-encode/decode round trip. This is the hot
// path used by VerifyJWTSignatureAndClaims and verifyLogoutTokenSignature.
@@ -378,13 +361,7 @@ func verifySignatureWithKey(tokenString string, pubKey crypto.PublicKey, alg str
if strings.HasPrefix(alg, "ES") {
var r, s big.Int
sigLen := len(signature)
// 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) {
if sigLen%2 != 0 {
return fmt.Errorf("invalid ECDSA signature length")
}
r.SetBytes(signature[:sigLen/2])
-53
View File
@@ -1,53 +0,0 @@
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")
}
}
-60
View File
@@ -51,43 +51,8 @@ const (
// sessionInvalidationTTL is how long to remember invalidated sessions
// Should be at least as long as your session max age
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
// as defined in OpenID Connect Back-Channel Logout 1.0
type LogoutTokenClaims struct {
@@ -226,10 +191,6 @@ func (t *TraefikOidc) handleFrontchannelLogout(rw http.ResponseWriter, req *http
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)
// Return a minimal HTML response that's suitable for iframe loading
@@ -617,11 +578,6 @@ func (t *TraefikOidc) isSessionInvalidated(sid, sub string, sessionCreatedAt tim
// buildSessionInvalidationKey creates a cache key for session invalidation
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)
}
@@ -644,22 +600,6 @@ func (t *TraefikOidc) sessionCreatedAtForInvalidation(idToken string, session *S
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
func (t *TraefikOidc) extractSessionInfo(idToken string) (sid, sub string, createdAt time.Time) {
if idToken == "" {
-34
View File
@@ -1,34 +0,0 @@
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)
}
})
}
}
-18
View File
@@ -1,18 +0,0 @@
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)
}
}
+1 -21
View File
@@ -7,7 +7,6 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -86,10 +85,8 @@ var defaultExcludedURLs = map[string]struct{}{
// headerTemplateFuncMap returns the function map available to custom header
// value templates, gated by the effective claims whitelist (built-in +
// config.AllowedClaims). It exposes exactly three helpers:
// config.AllowedClaims). It exposes exactly two helpers:
// - default: substitute a fallback when a value is nil/empty.
// - toJson: serialize a value (e.g. an object claim) to compact JSON; nil and
// "" yield "" so an absent claim skips the header instead of sending `""`.
// - get: safe map access RESTRICTED to whitelisted claim keys, so it cannot be
// used to read a non-whitelisted claim, a raw token, or the whole data map
// (issue #149 review). This is the sole runtime enforcement of the claims
@@ -105,16 +102,6 @@ func headerTemplateFuncMap(allowedClaims map[string]bool) template.FuncMap {
}
return val
},
"toJson": func(val interface{}) (string, error) {
if val == nil || val == "" {
return "", nil
}
b, err := json.Marshal(val)
if err != nil {
return "", err
}
return string(b), nil
},
"get": func(m interface{}, key string) interface{} {
if !allowedClaims[key] {
return ""
@@ -548,7 +535,6 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
logger.Debugf("TraefikOidc.New: logoutURLPath configured as: %q", t.logoutURLPath)
t.providerURL = config.ProviderURL
t.tokenCache.SetNamespace(t.tokenCacheNamespace())
// Use singleton resource manager for metadata initialization
rm := GetResourceManager()
@@ -945,7 +931,6 @@ func (t *TraefikOidc) performDynamicClientRegistration() {
if t.audience == "" {
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
// (RFC 7591 ClientMetadata.TokenEndpointAuthMethod) even when the
// static config did not set ClientAuthMethod=private_key_jwt. In that
@@ -1085,8 +1070,3 @@ func buildFullURL(scheme, host, path string) string {
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
}
+5 -44
View File
@@ -158,15 +158,6 @@ func (w *optionsPreflightWriter) Write(b []byte) (int, error) {
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.
// It is only used for logging and to decide whether extra side-effects
// (propagating the user header from an existing session) should run.
@@ -710,13 +701,6 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// Log waiting for initialization to help diagnose hanging requests
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
// request when initComplete fires quickly (would happen with time.After).
initTimer := time.NewTimer(30 * time.Second)
@@ -724,7 +708,6 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
select {
case <-t.initComplete:
atomic.AddInt32(&t.initWaiters, -1)
// Read issuerURL via atomic snapshot when available — replaces the
// metadataMu.RLock that previously fired on every non-bypass request.
// Under Yaegi each RLock acquisition costs 1-5ms of interpreter
@@ -761,12 +744,10 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
return
}
case <-req.Context().Done():
atomic.AddInt32(&t.initWaiters, -1)
t.logger.Debug("Request canceled while waiting for OIDC initialization")
t.sendErrorResponse(rw, req, "Request canceled", http.StatusRequestTimeout)
return
case <-initTimer.C:
atomic.AddInt32(&t.initWaiters, -1)
t.logger.Error("Timeout waiting for OIDC initialization")
t.sendErrorResponse(rw, req, "Timeout waiting for OIDC provider initialization - please try again later", http.StatusServiceUnavailable)
return
@@ -1054,7 +1035,8 @@ func (t *TraefikOidc) processAuthorizedRequestRS(rw http.ResponseWriter, req *ht
idToken := rs.idToken
if t.enableBackchannelLogout || t.enableFrontchannelLogout {
if idToken != "" {
sid, sub, createdAt := t.invalidationInputs(idToken, session)
sid, sub, _ := t.extractSessionInfo(idToken)
createdAt := t.sessionCreatedAtForInvalidation(idToken, session)
if t.isSessionInvalidated(sid, sub, createdAt) {
t.logger.Infof("Session for user %s has been invalidated via IdP-initiated logout", userIdentifier)
if err := session.Clear(req, rw); err != nil {
@@ -1146,7 +1128,8 @@ func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http
if t.enableBackchannelLogout || t.enableFrontchannelLogout {
idToken := session.GetIDToken()
if idToken != "" {
sid, sub, createdAt := t.invalidationInputs(idToken, session)
sid, sub, _ := t.extractSessionInfo(idToken)
createdAt := t.sessionCreatedAtForInvalidation(idToken, session)
if t.isSessionInvalidated(sid, sub, createdAt) {
t.logger.Infof("Session for user %s has been invalidated via IdP-initiated logout", userIdentifier)
// Clear the session and redirect to login
@@ -1341,23 +1324,6 @@ func joinBoundedClaimHeader(values []string) 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.
func (t *TraefikOidc) applyHeaderTemplates(req *http.Request, p *principal) {
if len(t.headerTemplates) == 0 {
@@ -1376,7 +1342,6 @@ func (t *TraefikOidc) applyHeaderTemplates(req *http.Request, p *principal) {
}
for headerName, tmpl := range t.headerTemplates {
dropInboundTemplated(req, headerName)
var buf bytes.Buffer
if err := tmpl.Execute(&buf, templateData); err != nil {
t.logger.Errorf("Failed to execute template for header %s: %v", headerName, err)
@@ -1406,11 +1371,7 @@ func (t *TraefikOidc) applyHeaderTemplates(req *http.Request, p *principal) {
// forwarding a tainted value. Do not log the value (it commonly
// carries the access token); log only name + reason.
if reason := headerValueReason(headerValue, headerTemplateMaxLen); reason != "" {
if reason == headerReasonTooLong {
t.logger.Errorf("Dropping templated header %s: rendered value exceeds %d bytes", headerName, headerTemplateMaxLen)
} else {
t.logger.Debugf("Dropping templated header %s: value failed sanitization (%s)", headerName, reason)
}
t.logger.Debugf("Dropping templated header %s: value failed sanitization (%s)", headerName, reason)
continue
}
req.Header.Set(headerName, headerValue)
+2 -19
View File
@@ -62,11 +62,10 @@ func (l *perSourceAuthLimiter) allow(req *http.Request) bool {
if now.Sub(l.lastSweep) >= time.Minute {
l.sweep(now)
}
key := limiterKey(ip)
e := l.entries[key]
e := l.entries[ip]
if e == nil {
e = &perSourceAuthEntry{limiter: rate.NewLimiter(l.limit, l.burst)}
l.entries[key] = e
l.entries[ip] = e
}
e.lastSeen = now
return e.limiter.Allow()
@@ -116,19 +115,3 @@ func isInternalSource(ip string) bool {
return p.IsLoopback() || p.IsPrivate() || p.IsLinkLocalUnicast() ||
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()
}
-49
View File
@@ -1,49 +0,0 @@
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)
}
}
}
-49
View File
@@ -1,49 +0,0 @@
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)
}
}
+13 -23
View File
@@ -672,6 +672,7 @@ func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool {
}
return &attemptState{
windowStartNano: nowNano,
attempts: 1,
}
})
return false
@@ -685,7 +686,7 @@ func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool {
}
next := *s
next.windowStartNano = nowNano
next.attempts = 0
next.attempts = 1
return &next
})
return false
@@ -823,16 +824,13 @@ func (rc *RefreshCoordinator) cleanupRoutine() {
// cleanupStaleEntries removes outdated tracking entries. Lock-free iteration
// via sync.Map.Range; safe to race with concurrent reads/writes.
func (rc *RefreshCoordinator) cleanupStaleEntries() {
now := time.Now()
cutoff := now.Add(-2 * rc.config.RefreshAttemptWindow).UnixNano()
nowNano := now.UnixNano()
cutoff := time.Now().Add(-2 * rc.config.RefreshAttemptWindow).UnixNano()
rc.sessionRefreshAttempts.Range(func(key, value interface{}) bool {
tracker := trackerFromMapValue(value)
if tracker == nil {
return true
}
st := tracker.stateOf()
if st.lastAttemptNano < cutoff && st.cooldownEndNano <= nowNano {
if tracker.stateOf().lastAttemptNano < cutoff {
// Compare-and-delete to avoid evicting a tracker that was just
// re-used by a concurrent caller. We compare by pointer identity.
rc.sessionRefreshAttempts.CompareAndDelete(key, value)
@@ -941,28 +939,20 @@ func (cb *RefreshCircuitBreaker) AllowRequest() bool {
return true
case 1: // open
lastFail := atomic.LoadInt64(&cb.lastFailureNano)
return cb.claimProbe(lastFail)
case 2: // half-open: one probe at a time; a stale probe is replaced
return cb.claimProbe(atomic.LoadInt64(&cb.lastFailureNano))
if time.Duration(time.Now().UnixNano()-lastFail) > cb.config.OpenDuration {
// Transition to half-open; first CAS winner gets the probe.
if atomic.CompareAndSwapInt32(&cb.state, 1, 2) {
return true
}
}
return false
case 2: // half-open
return true
default:
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.
func (cb *RefreshCircuitBreaker) RecordSuccess() {
switch atomic.LoadInt32(&cb.state) {
-93
View File
@@ -1,93 +0,0 @@
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")
}
}
+1 -25
View File
@@ -116,10 +116,6 @@ const (
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 (
// maxCookieSize is the maximum raw data size per chunk before securecookie encoding.
//
@@ -941,9 +937,6 @@ func (sm *SessionManager) EnhanceSessionSecurity(options *sessions.Options, r *h
// service). Keep it unless it is a strict parent of the serving
// host, which would scope the token-bearing cookie to sibling
// 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) {
host = fh
}
@@ -1031,7 +1024,6 @@ func (sm *SessionManager) CleanupOldCookies(w http.ResponseWriter, r *http.Reque
}
processedCookies := make(map[string]bool)
deletedCookies := 0
for _, cookie := range cookies {
// Check if cookie belongs to this middleware instance using the configured prefix
@@ -1045,8 +1037,7 @@ func (sm *SessionManager) CleanupOldCookies(w http.ResponseWriter, r *http.Reque
shouldCleanup := currentDomain != "" && !sm.cleanupDone
sm.cleanupMutex.RUnlock()
if shouldCleanup && deletedCookies < maxCleanupCookies {
deletedCookies++
if shouldCleanup {
for _, domain := range domainsToClean {
if domain == currentDomain || domain == "."+currentDomain || "."+domain == currentDomain {
continue
@@ -2302,13 +2293,6 @@ func (sd *SessionData) SetAccessToken(token string) {
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
// before storing the new chunked one.
sd.clearAccessTokenChunks()
@@ -2944,9 +2928,6 @@ func (sd *SessionData) GetIncomingPath() string {
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.
// This allows the user to be redirected to their originally requested resource
// after completing the authentication flow.
@@ -2955,11 +2936,6 @@ const maxIncomingPathLen = 2048
func (sd *SessionData) SetIncomingPath(path string) {
sd.sessionMutex.Lock()
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)
if currentVal != path {
sd.mainSession.Values["incoming_path"] = path
-36
View File
@@ -1,36 +0,0 @@
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)
}
}
+3 -4
View File
@@ -445,10 +445,9 @@ func (cm *ChunkManager) processChunkedToken(chunks map[int]*sessions.Session, co
// not clear cookies above n). token_total is the authoritative
// count; drop the excess rather than failing — which forced a
// re-auth and discarded the still-valid new token (R129).
for i := range chunks {
if i >= total {
delete(chunks, i)
}
n := len(chunks)
for i := total; i < n; i++ {
delete(chunks, i)
}
}
if len(chunks) < total {
-41
View File
@@ -1,41 +0,0 @@
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
View File
@@ -659,24 +659,6 @@ func (c *Config) Validate() error {
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
if c.Audience != "" {
// Validate audience format - should be a valid identifier or URL
@@ -972,7 +954,6 @@ func isValidSecureURL(s string) bool {
// isLoopbackHost reports whether host is "localhost" or a loopback IP literal
// (127.0.0.0/8 or ::1).
func isLoopbackHost(host string) bool {
host = canonicalHost(host)
if strings.EqualFold(host, "localhost") {
return true
}
@@ -1246,14 +1227,6 @@ func (c *Config) GetSecurityHeadersApplier() func(http.ResponseWriter, *http.Req
}
// 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
func (rc *RedisConfig) Validate() error {
if !rc.Enabled {
@@ -1280,9 +1253,6 @@ func (rc *RedisConfig) Validate() error {
if rc.PoolSize < 0 {
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 {
return fmt.Errorf("connect timeout cannot be negative")
}
@@ -1316,21 +1286,6 @@ func (rc *RedisConfig) Validate() error {
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
}
-73
View File
@@ -1,73 +0,0 @@
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)
}
})
}
+2 -5
View File
@@ -22,7 +22,6 @@ var allowedRootTokenFields = map[string]bool{
var validateTemplateParseFuncs = map[string]any{
"get": struct{}{},
"default": struct{}{},
"toJson": struct{}{},
}
// claimsWhitelist returns the effective set of claim field names a header
@@ -184,14 +183,12 @@ func (v *templateValidator) validateCommand(cmd *parse.CommandNode, dotIsRoot bo
return nil
}
// validateFunctionCall allows only get/default/toJson. get's map argument must be a
// validateFunctionCall allows only get/default. get's map argument must be a
// claims reference (never the root) and default's arguments are validated as
// values; the runtime get additionally enforces the key whitelist.
func (v *templateValidator) validateFunctionCall(name string, args []parse.Node, dotIsRoot bool) error {
switch name {
case "default", "toJson":
// toJson only serializes what its (validated) arguments or piped input
// already expose; it cannot reach anything a direct reference could not.
case "default":
for _, arg := range args {
if err := v.validateValue(arg, dotIsRoot); err != nil {
return err
-286
View File
@@ -1,286 +0,0 @@
package traefikoidc
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"text/template"
)
// renderHeaderTemplate runs tmplStr through the same validator, funcMap and
// applyHeaderTemplates path production uses. validateErr is the static-validation
// error; got is the forwarded header value ("" when absent or dropped).
func renderHeaderTemplate(t *testing.T, tmplStr string, extraClaims []string, claims map[string]interface{}) (got string, validateErr error) {
t.Helper()
allowed := claimsWhitelist(extraClaims)
if err := validateTemplateSecure(tmplStr, allowed); err != nil {
return "", err
}
tmpl, err := template.New("X-Test").Funcs(headerTemplateFuncMap(allowed)).Option("missingkey=zero").Parse(tmplStr)
if err != nil {
t.Fatalf("validated template failed to parse: %v", err)
}
o := &TraefikOidc{logger: NewLogger("error"), headerTemplates: map[string]*template.Template{"X-Test": tmpl}}
req := httptest.NewRequest("GET", "/", nil)
o.applyHeaderTemplates(req, &principal{Claims: claims, AccessToken: "AT-SECRET", IDToken: "IDT-SECRET", RefreshToken: "RT-SECRET"})
return req.Header.Get("X-Test"), nil
}
func TestToJson_ObjectClaimRoundTrips(t *testing.T) {
claims := map[string]interface{}{
"tenant": map[string]interface{}{"id": "t-1", "plan": "pro", "flags": []interface{}{"a", "b"}, "n": 3.0, "ok": true, "nil": nil},
}
for _, tmpl := range []string{
`{{ get .Claims "tenant" | toJson }}`,
`{{ toJson (get .Claims "tenant") }}`,
`{{ toJson .Claims.tenant }}`,
`{{ with .Claims.tenant }}{{ toJson . }}{{ end }}`,
`{{ $t := .Claims.tenant }}{{ toJson $t }}`,
`{{ toJson $.Claims.tenant }}`,
} {
got, err := renderHeaderTemplate(t, tmpl, []string{"tenant"}, claims)
if err != nil {
t.Fatalf("%s: unexpected validation error: %v", tmpl, err)
}
var back map[string]interface{}
if err := json.Unmarshal([]byte(got), &back); err != nil {
t.Fatalf("%s: output %q is not valid JSON: %v", tmpl, got, err)
}
if back["id"] != "t-1" || back["plan"] != "pro" || back["n"] != 3.0 || back["ok"] != true {
t.Fatalf("%s: round trip mismatch: %q", tmpl, got)
}
}
}
func TestToJson_ValueTypes(t *testing.T) {
claims := map[string]interface{}{
"groups": []interface{}{"admin", "users"},
"email": "[email protected]",
"exp": 1700000000.0,
"role": "",
}
cases := []struct{ name, tmpl, want string }{
{"array", `{{ toJson .Claims.groups }}`, `["admin","users"]`},
{"string", `{{ toJson .Claims.email }}`, `"[email protected]"`},
{"number", `{{ toJson .Claims.exp }}`, `1700000000`},
{"missing claim skips header", `{{ toJson .Claims.department }}`, ``},
{"missing via get skips header", `{{ get .Claims "department" | toJson }}`, ``},
{"empty string skips header", `{{ toJson .Claims.role }}`, ``},
{"literal string", `{{ toJson "x" }}`, `"x"`},
{"literal number", `{{ toJson 5 }}`, `5`},
{"composes with surrounding text", `v={{ toJson .Claims.groups }};`, `v=["admin","users"];`},
}
for _, c := range cases {
got, err := renderHeaderTemplate(t, c.tmpl, nil, claims)
if err != nil {
t.Fatalf("%s: unexpected validation error: %v", c.name, err)
}
if got != c.want {
t.Errorf("%s: got %q want %q", c.name, got, c.want)
}
}
}
func TestToJson_EmptyAndNilContainers(t *testing.T) {
claims := map[string]interface{}{
"realm_access": map[string]interface{}{},
"groups": []interface{}{},
}
if got, _ := renderHeaderTemplate(t, `{{ toJson .Claims.realm_access }}`, nil, claims); got != `{}` {
t.Errorf("empty object: got %q", got)
}
if got, _ := renderHeaderTemplate(t, `{{ toJson .Claims.groups }}`, nil, claims); got != `[]` {
t.Errorf("empty array: got %q", got)
}
if got, _ := renderHeaderTemplate(t, `{{ toJson .Claims.groups }}`, nil, nil); got != `` {
t.Errorf("nil claims: got %q", got)
}
}
func TestToJson_UnmarshalableValueDropsHeader(t *testing.T) {
fn := headerTemplateFuncMap(nil)["toJson"].(func(interface{}) (string, error))
if _, err := fn(make(chan int)); err == nil {
t.Fatal("expected error for unmarshalable value")
}
// A non-finite float is the only way a claim map could carry one in.
if _, err := fn(map[string]interface{}{"x": func() {}}); err == nil {
t.Fatal("expected error for func value")
}
}
// Security: toJson must not widen what a template can reach.
func TestToJson_SecurityBoundary(t *testing.T) {
claims := map[string]interface{}{
"email": "[email protected]",
"secret": "NOT-WHITELISTED",
"tenant": map[string]interface{}{"id": "t"},
}
rejected := []string{
`{{ toJson . }}`,
`{{ toJson $ }}`,
`{{ toJson .Claims }}`,
`{{ toJson $.Claims }}`,
`{{ .Claims | toJson }}`,
`{{ toJson .Claims.secret }}`,
`{{ toJson $.Claims.secret }}`,
`{{ toJson .AccessToken }}{{ toJson .Claims }}`,
`{{ get .Claims "secret" | toJson }}`,
`{{ toJson (get . "AccessToken") }}`,
`{{ get . "Claims" | toJson }}`,
`{{ with .Claims }}{{ toJson . }}{{ end }}`,
`{{ range .Claims }}{{ toJson . }}{{ end }}`,
`{{ toJson (index .Claims "secret") }}`,
`{{ toJson (printf "%v" .Claims) }}`,
`{{ toJson (call .Claims) }}`,
`{{ tojson .Claims.email }}`,
`{{ toJSON .Claims.email }}`,
`{{ toJson | js }}`,
}
for _, tmpl := range rejected {
if got, err := renderHeaderTemplate(t, tmpl, nil, claims); err == nil {
t.Errorf("%s: must be rejected by validation, rendered %q", tmpl, got)
}
}
// Dynamic key: static validation cannot see it, the runtime whitelist must.
dyn := `{{ get .Claims .Claims.email | toJson }}`
if got, err := renderHeaderTemplate(t, dyn, nil, map[string]interface{}{"email": "secret", "secret": "LEAK"}); err != nil || strings.Contains(got, "LEAK") {
t.Errorf("dynamic key leaked a non-whitelisted claim: err=%v got=%q", err, got)
}
}
// Security: tokens and the claims map never appear in output, even from a
// validated template that serializes a whitelisted claim.
func TestToJson_NoTokenLeak(t *testing.T) {
claims := map[string]interface{}{"tenant": map[string]interface{}{"id": "t"}}
got, err := renderHeaderTemplate(t, `{{ toJson .Claims.tenant }}`, []string{"tenant"}, claims)
if err != nil {
t.Fatal(err)
}
for _, s := range []string{"AT-SECRET", "IDT-SECRET", "RT-SECRET"} {
if strings.Contains(got, s) {
t.Fatalf("token %s leaked into %q", s, got)
}
}
}
// Security: a non-whitelisted claim stays unreachable until the operator opts
// in via allowedClaims, with or without toJson.
func TestToJson_RequiresAllowedClaims(t *testing.T) {
claims := map[string]interface{}{"tenant": map[string]interface{}{"id": "t"}}
if _, err := renderHeaderTemplate(t, `{{ toJson .Claims.tenant }}`, nil, claims); err == nil {
t.Error("direct reference to non-whitelisted claim must be rejected")
}
got, err := renderHeaderTemplate(t, `{{ get .Claims "tenant" | toJson }}`, nil, claims)
if err == nil {
t.Errorf("literal get key outside whitelist must be rejected, rendered %q", got)
}
}
// Security: hostile claim content must not produce header injection.
func TestToJson_HeaderInjectionHardening(t *testing.T) {
cases := []struct {
name string
value interface{}
// forwarded reports whether the header is expected to reach upstream.
forwarded bool
}{
{"CRLF is escaped to \\r\\n", map[string]interface{}{"x": "a\r\nX-Evil: 1"}, true},
{"NUL and control chars escaped", map[string]interface{}{"x": "a\x00\x07b"}, true},
{"LS/PS escaped", map[string]interface{}{"x": "a

b"}, true},
{"HTML chars escaped", map[string]interface{}{"x": "<script>&"}, true},
{"quote and backslash escaped", map[string]interface{}{"x": `"\`}, true},
{"bidi override dropped by sanitizer", map[string]interface{}{"x": "a\u202Eb"}, false},
{"bidi isolate dropped by sanitizer", map[string]interface{}{"x": "a\u2066b"}, false},
{"oversize dropped by sanitizer", map[string]interface{}{"x": strings.Repeat("A", headerTemplateMaxLen)}, false},
}
for _, c := range cases {
got, err := renderHeaderTemplate(t, `{{ toJson .Claims.realm_access }}`, nil, map[string]interface{}{"realm_access": c.value})
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
if !c.forwarded {
if got != "" {
t.Errorf("%s: tainted value must be dropped, got %d bytes", c.name, len(got))
}
continue
}
if got == "" {
t.Errorf("%s: safe escaped JSON must be forwarded", c.name)
continue
}
for _, r := range got {
if r < 0x20 || r == 0x7f || r == 0x2028 || r == 0x2029 {
t.Errorf("%s: raw control rune %U in %q", c.name, r, got)
}
}
if strings.ContainsAny(got, "<>&") {
t.Errorf("%s: unescaped HTML char in %q", c.name, got)
}
var back map[string]interface{}
if err := json.Unmarshal([]byte(got), &back); err != nil {
t.Errorf("%s: not valid JSON: %v", c.name, err)
}
}
}
// Default-preserving: existing templates are unaffected and toJson is not
// available to anything but validated header templates.
func TestToJson_ExistingTemplatesUnchanged(t *testing.T) {
claims := map[string]interface{}{"email": "[email protected]", "groups": []interface{}{"x", "y"}}
got, err := renderHeaderTemplate(t, `{{ .Claims.email }}|{{range $i, $e := .Claims.groups}}{{if $i}},{{end}}{{$e}}{{end}}|{{ default "d" .Claims.role }}`, nil, claims)
if err != nil || got != "[email protected]|x,y|d" {
t.Fatalf("got %q err=%v", got, err)
}
if _, ok := headerTemplateFuncMap(nil)["toJson"]; !ok {
t.Fatal("toJson missing from func map")
}
for _, name := range []string{"env", "expandenv", "printf", "call", "index", "js"} {
if _, ok := headerTemplateFuncMap(nil)[name]; ok {
t.Errorf("%s must not be exposed", name)
}
}
}
func TestToJson_ConfigValidate(t *testing.T) {
cfg := issue149BaseConfig()
cfg.AllowedClaims = []string{"tenant"}
cfg.Headers = []TemplatedHeader{{Name: "X-Tenant", Value: `{{ get .Claims "tenant" | toJson }}`}}
if err := cfg.Validate(); err != nil {
t.Fatalf("toJson header must validate: %v", err)
}
cfg.Headers = []TemplatedHeader{{Name: "X-All", Value: `{{ toJson .Claims }}`}}
if err := cfg.Validate(); err == nil {
t.Fatal("toJson of whole Claims must fail validation")
}
}
// Oversize output is operator-actionable, so it logs at error level (name and
// size limit only, never the value); other sanitizer drops stay at debug.
func TestToJson_OversizeLogsError(t *testing.T) {
run := func(claim map[string]interface{}) string {
var buf strings.Builder
logger := NewLogger("debug")
logger.logError.SetOutput(&buf)
tmpl := template.Must(template.New("X-Test").Funcs(headerTemplateFuncMap(nil)).Parse(`{{ toJson .Claims.realm_access }}`))
o := &TraefikOidc{logger: logger, headerTemplates: map[string]*template.Template{"X-Test": tmpl}}
req := httptest.NewRequest("GET", "/", nil)
o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"realm_access": claim}})
if req.Header.Get("X-Test") != "" {
t.Fatal("tainted header must be dropped")
}
return buf.String()
}
big := strings.Repeat("S3CRET", headerTemplateMaxLen)
out := run(map[string]interface{}{"x": big})
if !strings.Contains(out, "X-Test") || !strings.Contains(out, "exceeds") {
t.Errorf("oversize must log an error naming the header, got %q", out)
}
if strings.Contains(out, "S3CRET") {
t.Error("error log must not contain the value")
}
if out := run(map[string]interface{}{"x": "a\u202Eb"}); out != "" {
t.Errorf("non-length sanitizer drop must not log at error level, got %q", out)
}
}
-69
View File
@@ -1,69 +0,0 @@
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)
}
}
+2 -11
View File
@@ -53,7 +53,7 @@ type IntrospectionResponse struct {
func (t *TraefikOidc) introspectToken(token string) (*IntrospectionResponse, error) {
// Check cache first
if t.introspectionCache != nil {
if cached, found := t.introspectionCache.Get(t.introspectionKey(token)); found {
if cached, found := t.introspectionCache.Get(token); found {
if response, ok := cached.(*IntrospectionResponse); ok {
t.logger.Debugf("Using cached introspection result for token")
return response, nil
@@ -173,7 +173,7 @@ func (t *TraefikOidc) introspectToken(token string) (*IntrospectionResponse, err
cacheDuration = untilExp
}
}
t.introspectionCache.Set(t.introspectionKey(token), &introspectionResp, cacheDuration)
t.introspectionCache.Set(token, &introspectionResp, cacheDuration)
t.logger.Debugf("Cached introspection result for %v", cacheDuration)
}
@@ -280,12 +280,3 @@ func (t *TraefikOidc) validateOpaqueToken(token string) error {
t.logger.Debugf("Opaque token validation successful via introspection")
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
View File
@@ -812,7 +812,7 @@ func (t *TraefikOidc) RevokeToken(token string) {
// from the introspection cache after revocation. Next request
// re-introspects and sees active=false.
if t.introspectionCache != nil {
t.introspectionCache.Delete(t.introspectionKey(token))
t.introspectionCache.Delete(token)
}
d := blacklistDuration(token)
+2 -2
View File
@@ -15,7 +15,6 @@ package traefikoidc
import (
"encoding/base64"
"encoding/json"
"errors"
"strings"
"time"
)
@@ -267,7 +266,8 @@ func (t *TraefikOidc) validateStandardTokensRS(rs *requestState) (bool, bool, bo
var accessVerifyErr error
if err := t.verifyToken(rs.accessToken); err != nil {
accessVerifyErr = err
if errors.Is(err, errInvalidAudience) {
errMsg := err.Error()
if strings.Contains(errMsg, "invalid audience") || strings.Contains(errMsg, "audience") {
if t.strictAudienceValidation {
if rs.refreshToken != "" {
return false, true, false
-4
View File
@@ -111,9 +111,6 @@ type TraefikOidc struct {
// recovery attempt. Stored atomically so the hot ServeHTTP path can
// throttle retries without acquiring metadataRetryMutex on every request.
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
// the background-task bootstrap; then it flips to 1 via CAS. Replaces the
// firstRequestMutex + firstRequestReceived combo which previously took
@@ -227,7 +224,6 @@ type TraefikOidc struct {
dcrMu sync.Mutex
shutdownOnce sync.Once
sessionInvalidationCache CacheInterface
frontchannelMarkers markerQuota
refreshResultCache CacheInterface
minimalHeaders bool
stripAuthCookies bool
+2 -38
View File
@@ -380,10 +380,6 @@ func (c *UniversalCache) setLocalLocked(key string, value interface{}, ttl time.
growth = size - existing.Size
}
if c.rejectOversized(key, size) {
return
}
// Check memory limits
if c.config.MaxMemoryBytes > 0 {
// Evict items if necessary to make room
@@ -716,9 +712,6 @@ func (c *UniversalCache) getLocal(key string) (interface{}, bool) {
if gracePeriod, ok := item.Metadata["grace_period_active"].(bool); ok && gracePeriod {
if c.shouldExtendGracePeriod(item, now) {
newExpiry := c.calculateNewExpiry(item, now)
if _, set := item.Metadata[graceOriginKey]; !set {
item.Metadata[graceOriginKey] = item.ExpiresAt
}
item.ExpiresAt = newExpiry
c.logger.Infof("UniversalCache[%s]: Extended grace period for key=%s until %v",
c.config.Type, key, newExpiry)
@@ -922,29 +915,11 @@ 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
func (c *UniversalCache) SetMaxSize(newSize int) {
c.mu.Lock()
defer c.mu.Unlock()
if newSize < 1 {
newSize = 1 // a non-positive bound would spin the eviction loop forever
}
oldSize := c.config.MaxSize
c.config.MaxSize = newSize
@@ -1046,9 +1021,6 @@ 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
func (c *UniversalCache) shouldExtendGracePeriod(item *CacheItem, now time.Time) bool {
if c.config.MetadataConfig == nil {
@@ -1068,12 +1040,8 @@ func (c *UniversalCache) shouldExtendGracePeriod(item *CacheItem, now time.Time)
}
}
// Measure from the first expiry: later extensions overwrite ExpiresAt.
origin := item.ExpiresAt
if o, ok := item.Metadata[graceOriginKey].(time.Time); ok {
origin = o
}
timeSinceExpiry := now.Sub(origin)
// Calculate how long since the item originally expired
timeSinceExpiry := now.Sub(item.ExpiresAt)
return timeSinceExpiry <= maxGrace
}
@@ -1555,10 +1523,6 @@ func (c *UniversalCache) updateLocalCache(key string, value interface{}, ttl tim
growth = size - existing.Size
}
if c.rejectOversized(key, size) {
return nil
}
// Check memory limits
if c.config.MaxMemoryBytes > 0 {
for c.currentMemory+growth > c.config.MaxMemoryBytes && c.lruList.Len() > 0 {
-88
View File
@@ -1,88 +0,0 @@
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)
}
}
}
+3 -13
View File
@@ -453,15 +453,6 @@ func sameHost(a, b string) bool {
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.
// It prevents access to localhost, private networks, and known metadata endpoints.
// When allowPrivateIPAddresses is enabled, private IP checks are skipped.
@@ -471,14 +462,13 @@ func canonicalHost(host string) string {
// Returns:
// - An error if the host is dangerous or not allowed, nil if safe.
func (t *TraefikOidc) validateHost(host string) error {
hostname := canonicalHost(host)
if net.ParseIP(hostname) == nil && strings.Contains(hostname, ":") {
hostname := host
if strings.Contains(host, ":") {
var err error
hostname, _, err = net.SplitHostPort(hostname)
hostname, _, err = net.SplitHostPort(host)
if err != nil {
return fmt.Errorf("invalid host format: %w", err)
}
hostname = canonicalHost(hostname)
}
ip := net.ParseIP(hostname)
+1 -1
View File
@@ -14,4 +14,4 @@ const devPluginVersion = "0.0.0-dev"
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
// not the interpreted plugin). This build-stamped constant is therefore the
// single source of truth for the version reported by anonymous usage telemetry.
const traefikoidcPluginVersion = "1.0.62"
const traefikoidcPluginVersion = "1.0.42"