Compare commits

...
18 Commits
Author SHA1 Message Date
github-actions[bot] 230cd3a4fe chore(release): v1.0.62 2026-11-03 01:37:56 +00:00
lukaszraczylo 83da8e0bc2 fix(security): scope logout invalidation keys to the middleware instance
Back-channel logout keys for sub, sid and jti had no issuer or client
in them, in a cache shared by every instance. A logout for user u1 in
one realm logged out u1 in another, and an equal jti in two realms
looked like a replay. Keys now carry the instance prefix. An instance
without a prefix keeps the old key.
2026-11-03 01:31:35 +00:00
lukaszraczylo fd3fbbdbab fix(security): classify audience failures by sentinel error
The lenient-audience path chose itself when the verify error text
contained the word audience. The key lookup error embeds the kid from the
unverified token header. A kid of audience made a failed signature look
like an audience-only failure. verifyAudience now returns a sentinel and
the caller uses errors.Is.
2026-11-03 01:24:28 +00:00
lukaszraczylo 92c311fde1 fix(jwt): bind ECDSA curve to the algorithm and require fixed signature length
ES256, ES384 and ES512 verified against any curve and any even-length
signature. RFC 7518 fixes the curve per algorithm and the signature to
R||S at the curve's octet length. Verification now enforces both.
2026-11-03 01:21:01 +00:00
lukaszraczylo 2f0ee00e51 fix(security): scope introspection cache keys to the middleware instance
The introspection cache used the raw token as key in a shared cache.
Two instances with different providers or clients shared results. Keys
now use the same instance prefix as the verified-token cache.
2026-11-03 01:18:12 +00:00
lukaszraczylo 6e522fd212 fix(security): scope the verified-token cache to its middleware instance
The verified-token cache is one process-wide cache keyed by the raw
token. A verdict stored by one middleware instance satisfied any other
instance, so a token valid for one client or audience skipped the
issuer, audience and signature checks on another route. Each instance
now prefixes keys with its provider, client ID and audience. Dynamic
client registration updates the prefix.
2026-11-03 01:15:45 +00:00
lukaszraczylo 3ce5f428cf fix(session): keep old access token on failed store, cap incoming path, fold proto case
SetAccessToken cleared the old token before it found that no request
was available for the chunk cookies. The old token was lost.
The stored post-login path had no length bound and could bloat every
later Cookie header. It is now capped at 2048 bytes.
DetermineScheme compared X-Forwarded-Proto case-sensitively while the
cookie code did not, so HTTPS gave an http redirect_uri.
2026-11-03 01:10:36 +00:00
lukaszraczylo dc4da7b4c9 fix(security): drop client values for templated headers
A templated header skipped its Set when the render failed, came out
empty or failed sanitising. The client's own value of that header then
reached the backend. A client could forge a header such as X-Tenant by
using a token that lacks the claim. The middleware now removes the
inbound value first. Identity headers and Authorization are left alone.
2026-11-03 01:10:20 +00:00
lukaszraczylo 2cb42aa485 perf(logout): parse the ID token once for the invalidation check
With backchannel or frontchannel logout on, every authenticated request
parsed the ID token twice to read sid, sub and iat. One shared parse now
returns the same values. A test pins equality with the two separate
calls for valid, partial and malformed tokens. The gain is one parseJWT
call fewer per request. I did not measure it under yaegi.
2026-11-03 01:01:52 +00:00
lukaszraczylo 9ecd987416 fix(bearer): key the failure tracker by IPv6 /64
The bearer failure tracker keyed entries by the full source address.
One IPv6 host could create a new entry per request and grow the map for
window plus penalty seconds. It now shares one entry per /64, like the
login limiter. IPv4 keys do not change.
2026-11-03 00:59:16 +00:00
lukaszraczylo b1cf524bb4 fix(config): reject durations and sizes that overflow at startup
Several seconds settings became time.Duration with no upper bound, so
a large value wrapped negative or to zero. maxTokenAgeSeconds then
disabled the bearer age check, and a zero health check interval
panicked time.NewTicker. A circuit breaker threshold above MaxInt32
narrowed to a value that tripped on the first failure. A huge poolSize
allocated the connection channel up front. Validate now rejects these
values. Sane and default values are unchanged.
2026-11-03 00:53:12 +00:00
lukaszraczylo eec076f812 fix(resilience): recover from unhealthy and close half-open on successes
The health check promoted to healthy only when the fail counter was
above zero. The first success zeroed that counter, so a threshold of 2
or more never recovered. It now promotes on the status and the success
streak.

The breaker closed when admitted probes reached HalfOpenMaxRequests,
not when that many succeeded. A new counter tracks successes.
2026-11-03 00:46:46 +00:00
lukaszraczylo ac0fcfbe6a fix(redis): never send a zero or shortened expiry
Set and SetMany sent PSETEX with 0 for a TTL under 1ms, which Redis
rejects. They sent SETEX with truncated seconds for a fractional TTL,
so keys expired early. A shared helper now rounds up to a millisecond
and uses SETEX only for whole seconds.
2026-11-03 00:42:17 +00:00
lukaszraczylo 78db86b455 fix(security): treat trailing-dot hosts as their blocked names
validateHost and isLoopbackHost matched hostnames by exact string. The
spellings localhost. and metadata.google.internal. passed the SSRF
check. The check now drops the root dot and any IPv6 zone id first.
A bare IPv6 literal without a port no longer fails the host check.
2026-11-03 00:42:00 +00:00
lukaszraczylo 621f7993a8 fix(cache,jwt): bound grace, resize, memory cap and time claims
Grace extensions overwrote ExpiresAt, so a polled stale entry never hit
MaxGracePeriod. The bound now uses the first expiry.

SetMaxSize with a non-positive size spun the eviction loop forever. It
now clamps to 1.

One value larger than MaxMemoryBytes flushed every other entry and left
the cache over its cap. The cache now drops that value.

int64(float64) wrapped a far-future nbf or iat into the past, so the
token passed. Non-finite and out-of-range claims now fail.
2026-11-03 00:37:12 +00:00
lukaszraczylo a1b7059da1 fix(session): keep cooldown trackers and trim stale chunks by key
Cleanup evicted a refresh tracker by last attempt time alone. A tracker
could leave its cooldown early. Cleanup now also requires the cooldown
to be over.

The stale chunk trim deleted an index range derived from the chunk
count. A sparse key set kept stale high keys and failed the read. The
trim now deletes every key at or above token_total.
2026-11-03 00:15:52 +00:00
lukaszraczylo dae08b2c21 fix(refresh): reset window to zero attempts and admit one half-open probe
The window and cooldown resets set attempts to 1. The gate then counted
the same call again, so a reset window allowed one call fewer than a
fresh one. With a limit of 1 the session locked out at once. Resets now
start at 0.

The half-open breaker admitted every caller. It now admits one probe,
claimed with a compare-and-swap. A stale probe is replaced after
OpenDuration, so a lost probe cannot wedge the breaker.
2026-11-03 00:07:15 +00:00
lukaszraczylo 29a9b1f8f2 fix(ratelimit): key IPv6 sources by /64 prefix
The per-source limiter keyed buckets by the full IPv6 address. One host
holds a whole /64 and can mint 2^64 buckets. That host bypasses the
limit and grows the entries map. The limiter now shares one bucket per
/64. IPv4 keys do not change.
2026-11-03 00:02:07 +00:00
38 changed files with 1142 additions and 66 deletions
+24
View File
@@ -0,0 +1,24 @@
package traefikoidc
import (
"errors"
"fmt"
"testing"
)
// Only a real audience mismatch may select the lenient-audience path; an
// error text that merely embeds the attacker-chosen kid must not.
func TestAudienceErrorClassifiedBySentinel(t *testing.T) {
if !errors.Is(fmt.Errorf("standard claim verification failed: %w", verifyAudience("x", "y")), errInvalidAudience) {
t.Fatal("audience mismatch not recognized through wrapping")
}
if errors.Is(verifyAudience("y", "y"), errInvalidAudience) {
t.Fatal("matching audience classified as mismatch")
}
for _, kid := range []string{"audience", "invalid audience"} {
err := fmt.Errorf("failed to get public key: no matching public key found for kid: %s", kid)
if errors.Is(err, errInvalidAudience) {
t.Errorf("kid %q fooled the audience classifier", kid)
}
}
}
+3 -2
View File
@@ -558,9 +558,10 @@ func clientIPForBearer(req *http.Request) string {
}
host, _, err := net.SplitHostPort(req.RemoteAddr)
if err != nil {
return req.RemoteAddr
host = req.RemoteAddr
}
return host
// One IPv6 /64 shares a key, so a single host cannot mint 2^64 entries.
return limiterKey(host)
}
// --- Bearer auth entrypoint ---
+24
View File
@@ -0,0 +1,24 @@
package traefikoidc
import (
"net/http/httptest"
"testing"
)
// Addresses in one IPv6 /64 must share one failure-tracker key.
func TestClientIPForBearerCollapsesIPv6Prefix(t *testing.T) {
key := func(addr string) string {
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = addr
return clientIPForBearer(r)
}
if a, b := key("[2001:db8:1:2::1]:1"), key("[2001:db8:1:2:ffff::9]:2"); a != b {
t.Fatalf("same /64 got different keys: %q vs %q", a, b)
}
if a, b := key("[2001:db8:1:2::1]:1"), key("[2001:db8:1:3::1]:1"); a == b {
t.Fatalf("different /64 shared key %q", a)
}
if got := key("192.0.2.7:80"); got != "192.0.2.7" {
t.Fatalf("ipv4 key = %q", got)
}
}
+54
View File
@@ -0,0 +1,54 @@
package traefikoidc
import (
"net/http/httptest"
"testing"
"text/template"
)
func ownedHeaderOIDC(t *testing.T, names ...string) *TraefikOidc {
t.Helper()
m := map[string]*template.Template{}
for _, n := range names {
m[n] = template.Must(template.New(n).Option("missingkey=zero").Parse("{{.Claims.tenant}}"))
}
return &TraefikOidc{logger: NewLogger("error"), headerTemplates: m}
}
// A templated header the middleware owns must never carry a client value:
// an optional claim that renders empty must leave the header absent.
func TestTemplatedHeaderDropsClientSpoof(t *testing.T) {
o := ownedHeaderOIDC(t, "X-Tenant")
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Tenant", "admin-corp")
o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"sub": "u"}})
if got := req.Header.Get("X-Tenant"); got != "" {
t.Fatalf("client-supplied X-Tenant reached the backend: %q", got)
}
}
func TestTemplatedHeaderRenderedValueWins(t *testing.T) {
o := ownedHeaderOIDC(t, "X-Tenant")
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Tenant", "evil")
o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"tenant": "acme"}})
if got := req.Header.Get("X-Tenant"); got != "acme" {
t.Fatalf("X-Tenant = %q, want acme", got)
}
}
// Identity headers are injected before templates run; an empty render must
// not clobber them, and Authorization stays as received.
func TestTemplatedHeaderKeepsIdentityAndAuthorization(t *testing.T) {
o := ownedHeaderOIDC(t, "X-Forwarded-User", "Authorization")
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Forwarded-User", "alice")
req.Header.Set("Authorization", "Bearer abc")
o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"sub": "u"}})
if got := req.Header.Get("X-Forwarded-User"); got != "alice" {
t.Fatalf("identity header clobbered: %q", got)
}
if got := req.Header.Get("Authorization"); got != "Bearer abc" {
t.Fatalf("Authorization changed: %q", got)
}
}
+31 -3
View File
@@ -13,6 +13,7 @@ import (
"net/http/cookiejar"
"net/url"
"strings"
"sync/atomic"
"time"
)
@@ -293,10 +294,37 @@ func extractClaims(tokenString string) (map[string]interface{}, error) {
// TokenCache provides a specialized cache for JWT tokens and their parsed claims.
// 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 {
@@ -313,7 +341,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 = "t-" + token
token = tc.key(token)
_ = tc.cache.Set(token, claims, expiration) // Safe to ignore: cache failures are non-critical
}
@@ -325,7 +353,7 @@ func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiratio
// - map[string]interface{}: The cached claims if found
// - 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 = "t-" + token
token = tc.key(token)
value, found := tc.cache.Get(token)
if !found {
return nil, false
@@ -338,7 +366,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 = "t-" + token
token = tc.key(token)
tc.cache.Delete(token)
}
+25
View File
@@ -0,0 +1,25 @@
package traefikoidc
import "testing"
func TestValidateHostCanonicalForms(t *testing.T) {
o := &TraefikOidc{}
blocked := []string{
"localhost.", "LOCALHOST.", "metadata.google.internal.", "metadata.google.internal",
"169.254.169.254.", "fe80::1%eth0", "[fe80::1]", "::1",
}
for _, h := range blocked {
if err := o.validateHost(h); err == nil {
t.Errorf("validateHost(%q) = nil, want blocked", h)
}
}
allowed := []string{"idp.example.com", "idp.example.com.", "2606:4700::1111"}
for _, h := range allowed {
if err := o.validateHost(h); err != nil {
t.Errorf("validateHost(%q) = %v, want allowed", h, err)
}
}
if !isLoopbackHost("localhost.") {
t.Error("isLoopbackHost(localhost.) = false")
}
}
+20 -18
View File
@@ -120,15 +120,8 @@ func (r *RedisBackend) Set(ctx context.Context, key string, value []byte, ttl ti
// Use PSETEX for millisecond precision, SETEX for second precision
if ttl > 0 {
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))
}
cmd, arg := ttlCommand(ttl)
_, err = conn.Do(cmd, prefixedKey, arg, string(value))
} else {
_, err = conn.Do("SET", prefixedKey, string(value))
}
@@ -624,6 +617,19 @@ 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 {
@@ -658,20 +664,16 @@ func (r *RedisBackend) SetMany(ctx context.Context, items map[string][]byte, ttl
pipeline := conn.NewPipeline()
// Queue all SET commands
ttlSeconds := int(ttl.Seconds())
ttlMillis := ttl.Milliseconds()
var ttlCmd, ttlArg string
if ttl > 0 {
ttlCmd, ttlArg = ttlCommand(ttl)
}
for key, value := range items {
prefixedKey := r.prefixKey(key)
if ttl > 0 {
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))
}
pipeline.Queue(ttlCmd, prefixedKey, ttlArg, string(value))
} else {
pipeline.Queue("SET", prefixedKey, string(value))
}
+45
View File
@@ -0,0 +1,45 @@
package backends
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestRedisSetTTLNeverZeroOrEarly(t *testing.T) {
mr := NewMiniredisServer(t)
b, err := NewRedisBackend(DefaultRedisConfig(mr.GetAddr()))
require.NoError(t, err)
defer b.Close()
ctx := context.Background()
t.Run("sub-millisecond ttl is accepted", func(t *testing.T) {
require.NoError(t, b.Set(ctx, "sub", []byte("v"), 500*time.Microsecond))
})
t.Run("sub-millisecond ttl accepted by SetMany", func(t *testing.T) {
require.NoError(t, b.SetMany(ctx, map[string][]byte{"sub-many": []byte("v")}, 500*time.Microsecond))
})
t.Run("fractional seconds are not rounded down", func(t *testing.T) {
require.NoError(t, b.Set(ctx, "frac", []byte("v"), 1999*time.Millisecond))
_, ttl, ok, err := b.Get(ctx, "frac")
require.NoError(t, err)
require.True(t, ok)
require.Greater(t, ttl, time.Second, "1999ms ttl must not be stored as 1s")
})
t.Run("fractional seconds SetMany", func(t *testing.T) {
require.NoError(t, b.SetMany(ctx, map[string][]byte{"frac-many": []byte("v")}, 1999*time.Millisecond))
_, ttl, ok, err := b.Get(ctx, "frac-many")
require.NoError(t, err)
require.True(t, ok)
require.Greater(t, ttl, time.Second)
})
t.Run("whole seconds still work", func(t *testing.T) {
require.NoError(t, b.Set(ctx, "whole", []byte("v"), 5*time.Second))
_, ttl, ok, err := b.Get(ctx, "whole")
require.NoError(t, err)
require.True(t, ok)
require.Greater(t, ttl, 3*time.Second)
})
}
+6 -3
View File
@@ -81,6 +81,7 @@ type CircuitBreaker struct {
stateMu sync.RWMutex
timeMu sync.RWMutex
halfOpenRequests atomic.Int32
halfOpenSuccesses atomic.Int32
consecutiveFailures atomic.Int32
state atomic.Int32
}
@@ -170,12 +171,12 @@ func (cb *CircuitBreaker) RecordSuccess() {
case StateHalfOpen:
// If we've had enough successful requests, close the circuit
successfulRequests := cb.halfOpenRequests.Load()
// #nosec G115 -- HalfOpenMaxRequests is a small config value that fits in int32
if successfulRequests >= int32(cb.config.HalfOpenMaxRequests) {
successfulRequests := cb.halfOpenSuccesses.Add(1)
if int64(successfulRequests) >= int64(cb.config.HalfOpenMaxRequests) {
cb.setState(StateClosed)
cb.consecutiveFailures.Store(0)
cb.halfOpenRequests.Store(0)
cb.halfOpenSuccesses.Store(0)
}
}
}
@@ -216,6 +217,7 @@ 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)
@@ -251,6 +253,7 @@ 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)
+4 -3
View File
@@ -168,11 +168,12 @@ 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 {
fails := h.consecutiveFails.Swap(0)
h.consecutiveFails.Store(0)
oks := h.consecutiveOK.Add(1)
// Check if we should transition to healthy
if fails > 0 && oks >= int32(h.config.HealthyThreshold) {
// 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) {
oldStatus := HealthStatus(h.status.Swap(int32(HealthHealthy)))
if oldStatus != HealthHealthy && h.config.OnStatusChange != nil {
h.config.OnStatusChange(oldStatus, HealthHealthy)
+56
View File
@@ -0,0 +1,56 @@
package resilience
import (
"testing"
"time"
)
// With HealthyThreshold >= 2 an Unhealthy backend must recover once it
// answers HealthyThreshold times in a row.
func TestHealthCheckRecoversWithThresholdTwo(t *testing.T) {
h := &HealthCheckBackend{config: &HealthCheckConfig{HealthyThreshold: 2, UnhealthyThreshold: 3}}
for i := 0; i < 6; i++ {
h.recordResult(false)
}
if HealthStatus(h.status.Load()) != HealthUnhealthy {
t.Fatalf("precondition: status=%v, want unhealthy", HealthStatus(h.status.Load()))
}
for i := 0; i < 5; i++ {
h.recordResult(true)
}
if got := HealthStatus(h.status.Load()); got != HealthHealthy {
t.Fatalf("status after 5 successes = %v, want healthy", got)
}
}
// Half-open closes after HalfOpenMaxRequests successes, not admissions.
func TestHalfOpenClosesOnSuccessesNotAdmissions(t *testing.T) {
cb := NewCircuitBreaker(&CircuitBreakerConfig{
MaxFailures: 1, Timeout: time.Millisecond, HalfOpenMaxRequests: 3, ResetTimeout: time.Second,
})
cb.RecordFailure()
time.Sleep(20 * time.Millisecond)
for i := 0; i < 3; i++ {
if !cb.AllowRequest() {
t.Fatalf("probe %d rejected", i)
}
}
cb.RecordSuccess()
if cb.GetState() != StateHalfOpen {
t.Fatalf("closed after 1 of 3 probe successes; state=%v", cb.GetState())
}
cb.RecordFailure()
if cb.GetState() != StateOpen {
t.Fatalf("a failing probe must reopen; state=%v", cb.GetState())
}
time.Sleep(20 * time.Millisecond)
for i := 0; i < 3; i++ {
cb.AllowRequest()
}
for i := 0; i < 3; i++ {
cb.RecordSuccess()
}
if cb.GetState() != StateClosed {
t.Fatalf("state after 3 successes = %v, want closed", cb.GetState())
}
}
+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 := req.Header.Get("X-Forwarded-Proto"); scheme == "https" || scheme == "http" {
if scheme := strings.ToLower(req.Header.Get("X-Forwarded-Proto")); scheme == "https" || scheme == "http" {
return scheme
}
+26 -3
View File
@@ -6,7 +6,9 @@ import (
"crypto/ecdsa"
"crypto/rsa"
"encoding/base64"
"errors"
"fmt"
"math"
"math/big"
"strings"
"time"
@@ -194,11 +196,16 @@ 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 fmt.Errorf("invalid audience")
return errInvalidAudience
}
case []interface{}:
found := false
@@ -209,7 +216,7 @@ func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
}
}
if !found {
return fmt.Errorf("invalid audience")
return errInvalidAudience
}
default:
return fmt.Errorf("invalid 'aud' claim type")
@@ -231,6 +238,9 @@ 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:
@@ -241,6 +251,10 @@ func verifyIssuer(tokenIssuer, expectedIssuer string) error {
// 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()
@@ -314,6 +328,9 @@ 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.
@@ -361,7 +378,13 @@ func verifySignatureWithKey(tokenString string, pubKey crypto.PublicKey, alg str
if strings.HasPrefix(alg, "ES") {
var r, s big.Int
sigLen := len(signature)
if sigLen%2 != 0 {
// RFC 7518 3.4: the curve is fixed by the algorithm and the
// signature is R||S at exactly the curve's octet length.
bits := ecdsaAlgBits[alg]
if bits == 0 || pubKey.Curve.Params().BitSize != bits {
return fmt.Errorf("ECDSA key curve does not match algorithm %s", alg)
}
if sigLen != 2*((bits+7)/8) {
return fmt.Errorf("invalid ECDSA signature length")
}
r.SetBytes(signature[:sigLen/2])
+53
View File
@@ -0,0 +1,53 @@
package traefikoidc
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"testing"
)
func signES(t *testing.T, key *ecdsa.PrivateKey, signed string, h256 bool, size int) (sigRS []byte) {
t.Helper()
var digest []byte
if h256 {
d := sha256.Sum256([]byte(signed))
digest = d[:]
} else {
d := sha512.Sum384([]byte(signed))
digest = d[:]
}
r, s, err := ecdsa.Sign(rand.Reader, key, digest)
if err != nil {
t.Fatal(err)
}
out := make([]byte, 2*size)
r.FillBytes(out[:size])
s.FillBytes(out[size:])
return out
}
func TestECDSAVerifyIsStrict(t *testing.T) {
const signed = "eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJ1In0"
enc := base64.RawURLEncoding.EncodeToString
p256, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
p384, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err := verifySignatureWithKey(signed+"."+enc(signES(t, p256, signed, true, 32)), &p256.PublicKey, "ES256"); err != nil {
t.Fatalf("valid ES256 rejected: %v", err)
}
if err := verifySignatureWithKey(signed+"."+enc(signES(t, p384, signed, false, 48)), &p384.PublicKey, "ES384"); err != nil {
t.Fatalf("valid ES384 rejected: %v", err)
}
// ES256 token verified against a P-384 key.
if err := verifySignatureWithKey(signed+"."+enc(signES(t, p384, signed, true, 48)), &p384.PublicKey, "ES256"); err == nil {
t.Error("ES256 accepted with a P-384 key")
}
// Zero-padded halves: 66 bytes for ES256.
if err := verifySignatureWithKey(signed+"."+enc(signES(t, p256, signed, true, 33)), &p256.PublicKey, "ES256"); err == nil {
t.Error("ES256 accepted a 66-byte signature")
}
}
+21
View File
@@ -617,6 +617,11 @@ 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)
}
@@ -639,6 +644,22 @@ 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
@@ -0,0 +1,34 @@
package traefikoidc
import (
"testing"
"github.com/gorilla/sessions"
)
// invalidationInputs must return exactly what extractSessionInfo and
// sessionCreatedAtForInvalidation return when called separately.
func TestInvalidationInputsMatchSeparateCalls(t *testing.T) {
oidc := &TraefikOidc{logger: newNoOpLogger()}
sess := &SessionData{mainSession: &sessions.Session{Values: map[interface{}]interface{}{"created_at": int64(1700000000)}}}
tokens := map[string]string{
"all claims": makeTestJWT(t, map[string]interface{}{"sid": "s1", "sub": "u1", "iat": float64(1700000100)}),
"no iat": makeTestJWT(t, map[string]interface{}{"sid": "s1", "sub": "u1"}),
"sid only": makeTestJWT(t, map[string]interface{}{"sid": "s1", "iat": float64(1700000200)}),
"sub only": makeTestJWT(t, map[string]interface{}{"sub": "u1"}),
"wrong types": makeTestJWT(t, map[string]interface{}{"sid": 5, "sub": true, "iat": "x"}),
"malformed": "not-a-jwt",
"empty": "",
}
for name, tok := range tokens {
t.Run(name, func(t *testing.T) {
wantSID, wantSub, _ := oidc.extractSessionInfo(tok)
wantAt := oidc.sessionCreatedAtForInvalidation(tok, sess)
gotSID, gotSub, gotAt := oidc.invalidationInputs(tok, sess)
if gotSID != wantSID || gotSub != wantSub || !gotAt.Equal(wantAt) {
t.Fatalf("got (%q,%q,%v) want (%q,%q,%v)", gotSID, gotSub, gotAt, wantSID, wantSub, wantAt)
}
})
}
}
+18
View File
@@ -0,0 +1,18 @@
package traefikoidc
import "testing"
func TestSessionInvalidationKeyScopedPerInstance(t *testing.T) {
a := &TraefikOidc{tokenCache: &TokenCache{}}
b := &TraefikOidc{tokenCache: &TokenCache{}}
a.tokenCache.SetNamespace("https://kc/realms/a|c|c")
b.tokenCache.SetNamespace("https://kc/realms/b|c|c")
for _, kind := range []string{"sub", "sid", "jti"} {
if a.buildSessionInvalidationKey(kind, "u1") == b.buildSessionInvalidationKey(kind, "u1") {
t.Errorf("%s key shared across issuers", kind)
}
}
if got := (&TraefikOidc{}).buildSessionInvalidationKey("sub", "u1"); got != "session_invalidation:sub:u1" {
t.Errorf("namespace-less key changed: %q", got)
}
}
+7
View File
@@ -548,6 +548,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
logger.Debugf("TraefikOidc.New: logoutURLPath configured as: %q", t.logoutURLPath)
t.providerURL = config.ProviderURL
t.tokenCache.SetNamespace(t.tokenCacheNamespace())
// Use singleton resource manager for metadata initialization
rm := GetResourceManager()
@@ -944,6 +945,7 @@ 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
@@ -1083,3 +1085,8 @@ 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
}
+20 -4
View File
@@ -1054,8 +1054,7 @@ func (t *TraefikOidc) processAuthorizedRequestRS(rw http.ResponseWriter, req *ht
idToken := rs.idToken
if t.enableBackchannelLogout || t.enableFrontchannelLogout {
if idToken != "" {
sid, sub, _ := t.extractSessionInfo(idToken)
createdAt := t.sessionCreatedAtForInvalidation(idToken, session)
sid, sub, createdAt := t.invalidationInputs(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 {
@@ -1147,8 +1146,7 @@ func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http
if t.enableBackchannelLogout || t.enableFrontchannelLogout {
idToken := session.GetIDToken()
if idToken != "" {
sid, sub, _ := t.extractSessionInfo(idToken)
createdAt := t.sessionCreatedAtForInvalidation(idToken, session)
sid, sub, createdAt := t.invalidationInputs(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
@@ -1343,6 +1341,23 @@ 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 {
@@ -1361,6 +1376,7 @@ 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)
+19 -2
View File
@@ -62,10 +62,11 @@ func (l *perSourceAuthLimiter) allow(req *http.Request) bool {
if now.Sub(l.lastSweep) >= time.Minute {
l.sweep(now)
}
e := l.entries[ip]
key := limiterKey(ip)
e := l.entries[key]
if e == nil {
e = &perSourceAuthEntry{limiter: rate.NewLimiter(l.limit, l.burst)}
l.entries[ip] = e
l.entries[key] = e
}
e.lastSeen = now
return e.limiter.Allow()
@@ -115,3 +116,19 @@ 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
@@ -0,0 +1,49 @@
package traefikoidc
import (
"fmt"
"net/http/httptest"
"testing"
)
// An IPv6 /64 holder must share one bucket; per-address keys let one host
// mint 2^64 buckets and bypass the throttle.
func TestPerSourceLimiter_IPv6SamePrefixSharesBucket(t *testing.T) {
const burst = 3
l := newPerSourceAuthLimiter(burst)
allowed := 0
for i := 0; i < 50; i++ {
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = fmt.Sprintf("[2001:db8:1:2::%x]:1234", i+1)
if l.allow(req) {
allowed++
}
}
if allowed > burst {
t.Fatalf("one /64 got %d allows, want <= %d", allowed, burst)
}
}
func TestPerSourceLimiter_IPv6DifferentPrefixIndependent(t *testing.T) {
l := newPerSourceAuthLimiter(1)
a := httptest.NewRequest("GET", "/", nil)
a.RemoteAddr = "[2001:db8:1:2::1]:1"
b := httptest.NewRequest("GET", "/", nil)
b.RemoteAddr = "[2001:db8:1:3::1]:1"
if !l.allow(a) || !l.allow(b) {
t.Fatal("distinct /64 prefixes must have independent buckets")
}
}
func TestLimiterKey(t *testing.T) {
cases := map[string]string{
"192.0.2.1": "192.0.2.1",
"2001:db8:1:2::9": "2001:db8:1:2::/64",
"::ffff:192.0.2.1": "192.0.2.1",
}
for in, want := range cases {
if got := limiterKey(in); got != want {
t.Errorf("limiterKey(%q)=%q want %q", in, got, want)
}
}
}
+49
View File
@@ -0,0 +1,49 @@
package traefikoidc
import (
"testing"
"time"
"github.com/gorilla/sessions"
)
// Cleanup must not evict a tracker whose cooldown is still running.
func TestRefreshCleanupKeepsTrackerInCooldown(t *testing.T) {
cfg := DefaultRefreshCoordinatorConfig()
cfg.MaxRefreshAttempts = 2
cfg.RefreshAttemptWindow = 20 * time.Millisecond
cfg.RefreshCooldownPeriod = time.Hour
rc := NewRefreshCoordinator(cfg, GetSingletonNoOpLogger())
defer rc.Shutdown()
rc.recordRefreshAttempt("s")
rc.recordRefreshAttempt("s")
if !rc.isInCooldown("s") {
t.Fatal("precondition: session must enter cooldown")
}
time.Sleep(80 * time.Millisecond)
rc.cleanupStaleEntries()
if !rc.isInCooldown("s") {
t.Fatal("cleanup evicted a tracker still in cooldown")
}
}
// Stale chunks above token_total must be dropped by key, even when the key
// set is sparse.
func TestChunkTrimDropsStaleSparseKeys(t *testing.T) {
cm := NewChunkManager(newNoOpLogger())
mk := func(s string, total interface{}) *sessions.Session {
v := map[interface{}]interface{}{"token_chunk": s}
if total != nil {
v["token_total"] = total
}
return &sessions.Session{Values: v}
}
chunks := map[int]*sessions.Session{
0: mk("Zq8xK2mP", 3), 1: mk("r7TnB4wY", nil), 2: mk("c9LdG5sH", nil), 7: mk("stale", nil),
}
res := cm.processChunkedToken(chunks, RefreshTokenConfig)
if res.Error != nil || res.Token != "Zq8xK2mPr7TnB4wYc9LdG5sH" {
t.Fatalf("token=%q err=%v, want Zq8xK2mPr7TnB4wYc9LdG5sH", res.Token, res.Error)
}
}
+23 -13
View File
@@ -672,7 +672,6 @@ func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool {
}
return &attemptState{
windowStartNano: nowNano,
attempts: 1,
}
})
return false
@@ -686,7 +685,7 @@ func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool {
}
next := *s
next.windowStartNano = nowNano
next.attempts = 1
next.attempts = 0
return &next
})
return false
@@ -824,13 +823,16 @@ 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() {
cutoff := time.Now().Add(-2 * rc.config.RefreshAttemptWindow).UnixNano()
now := time.Now()
cutoff := now.Add(-2 * rc.config.RefreshAttemptWindow).UnixNano()
nowNano := now.UnixNano()
rc.sessionRefreshAttempts.Range(func(key, value interface{}) bool {
tracker := trackerFromMapValue(value)
if tracker == nil {
return true
}
if tracker.stateOf().lastAttemptNano < cutoff {
st := tracker.stateOf()
if st.lastAttemptNano < cutoff && st.cooldownEndNano <= nowNano {
// Compare-and-delete to avoid evicting a tracker that was just
// re-used by a concurrent caller. We compare by pointer identity.
rc.sessionRefreshAttempts.CompareAndDelete(key, value)
@@ -939,20 +941,28 @@ func (cb *RefreshCircuitBreaker) AllowRequest() bool {
return true
case 1: // open
lastFail := 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
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))
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
@@ -0,0 +1,93 @@
package traefikoidc
import (
"sync"
"sync/atomic"
"testing"
"time"
)
// After a window reset the tracker must allow the full MaxRefreshAttempts,
// the same as a fresh tracker.
func TestRefreshWindowResetAllowsFullQuota(t *testing.T) {
cfg := DefaultRefreshCoordinatorConfig()
cfg.MaxRefreshAttempts = 3
cfg.RefreshAttemptWindow = 50 * time.Millisecond
cfg.RefreshCooldownPeriod = time.Hour
rc := NewRefreshCoordinator(cfg, GetSingletonNoOpLogger())
defer rc.Shutdown()
pass := func() int {
n := 0
for i := 0; i < 10; i++ {
if rc.isInCooldown("s") {
break
}
rc.recordRefreshAttempt("s")
n++
}
return n
}
if got := pass(); got != 3 {
t.Fatalf("fresh window passes=%d want 3", got)
}
// Window expiry alone cannot reset while in cooldown, so use a new
// session that never hits the cap: record 1, wait out the window.
rc.recordRefreshAttempt("w")
time.Sleep(80 * time.Millisecond)
n := 0
for i := 0; i < 10; i++ {
if rc.isInCooldown("w") {
break
}
rc.recordRefreshAttempt("w")
n++
}
if n != 3 {
t.Fatalf("post-reset window passes=%d want 3", n)
}
}
// Half-open admits a single probe until it reports.
func TestRefreshBreakerHalfOpenSingleProbe(t *testing.T) {
cb := &RefreshCircuitBreaker{config: RefreshCircuitBreakerConfig{
MaxFailures: 1, OpenDuration: 20 * time.Millisecond, HalfOpenRequests: 1,
}}
cb.RecordFailure()
time.Sleep(40 * time.Millisecond)
var admitted int32
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if cb.AllowRequest() {
atomic.AddInt32(&admitted, 1)
}
}()
}
wg.Wait()
if admitted != 1 {
t.Fatalf("half-open admitted %d, want 1", admitted)
}
cb.RecordSuccess()
if !cb.AllowRequest() {
t.Fatal("breaker must close after probe success")
}
}
// A probe that never reports must not wedge the breaker.
func TestRefreshBreakerHalfOpenProbeTimeout(t *testing.T) {
cb := &RefreshCircuitBreaker{config: RefreshCircuitBreakerConfig{
MaxFailures: 1, OpenDuration: 20 * time.Millisecond, HalfOpenRequests: 1,
}}
cb.RecordFailure()
time.Sleep(40 * time.Millisecond)
if !cb.AllowRequest() || cb.AllowRequest() {
t.Fatal("want exactly one probe")
}
time.Sleep(40 * time.Millisecond)
if !cb.AllowRequest() {
t.Fatal("stale probe must allow a new one")
}
}
+15
View File
@@ -2302,6 +2302,13 @@ 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()
@@ -2937,6 +2944,9 @@ 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.
@@ -2945,6 +2955,11 @@ func (sd *SessionData) GetIncomingPath() string {
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
@@ -0,0 +1,36 @@
package traefikoidc
import (
"crypto/rand"
"encoding/base64"
"net/http/httptest"
"testing"
)
// A chunked store that cannot run (no request to bind chunk cookies to) must
// leave the previous access token intact.
func TestSetAccessTokenNilRequestKeepsOldToken(t *testing.T) {
sm, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("error"))
if err != nil {
t.Fatal(err)
}
sess, err := sm.GetSession(httptest.NewRequest("GET", "/", nil))
if err != nil {
t.Fatal(err)
}
t.Cleanup(sess.returnToPoolSafely)
const old = "smallaccesstokenvaluevaluevalue"
sess.SetAccessToken(old)
blob := make([]byte, 3000)
if _, err := rand.Read(blob); err != nil {
t.Fatal(err)
}
sess.request = nil
sess.SetAccessToken(base64.StdEncoding.EncodeToString(blob))
if got := sess.GetAccessToken(); got != old {
t.Fatalf("old access token lost after a failed chunked store: %q", got)
}
}
+4 -3
View File
@@ -445,9 +445,10 @@ 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).
n := len(chunks)
for i := total; i < n; i++ {
delete(chunks, i)
for i := range chunks {
if i >= total {
delete(chunks, i)
}
}
}
if len(chunks) < total {
+41
View File
@@ -0,0 +1,41 @@
package traefikoidc
import (
"net/http/httptest"
"strings"
"testing"
"github.com/lukaszraczylo/traefikoidc/internal/utils"
)
// A deep link longer than the cap must not be persisted into the cookies.
func TestSetIncomingPathCapsLength(t *testing.T) {
sm, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("error"))
if err != nil {
t.Fatal(err)
}
sess, err := sm.GetSession(httptest.NewRequest("GET", "/", nil))
if err != nil {
t.Fatal(err)
}
t.Cleanup(sess.returnToPoolSafely)
sess.SetIncomingPath("/ok?a=1")
if got := sess.GetIncomingPath(); got != "/ok?a=1" {
t.Fatalf("short path changed: %q", got)
}
sess.SetIncomingPath("/x?" + strings.Repeat("q", maxIncomingPathLen+1))
if got := sess.GetIncomingPath(); got != "" {
t.Fatalf("oversized path stored (%d bytes)", len(got))
}
}
func TestDetermineSchemeCaseInsensitive(t *testing.T) {
for in, want := range map[string]string{"HTTPS": "https", "Https": "https", "HTTP": "http", "https": "https"} {
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("X-Forwarded-Proto", in)
if got := utils.DetermineScheme(r, false); got != want {
t.Errorf("X-Forwarded-Proto %q -> %q, want %q", in, got, want)
}
}
}
+45
View File
@@ -659,6 +659,24 @@ func (c *Config) Validate() error {
return fmt.Errorf("sessionMaxAge is too large; must be at most %d seconds", math.MaxInt64/int64(time.Second))
}
// 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
@@ -954,6 +972,7 @@ 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
}
@@ -1227,6 +1246,14 @@ 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 {
@@ -1253,6 +1280,9 @@ 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")
}
@@ -1286,6 +1316,21 @@ 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
@@ -0,0 +1,73 @@
package traefikoidc
import (
"math"
"testing"
"time"
)
func boundsTestConfig() *Config {
cfg := CreateConfig()
cfg.ProviderURL = "https://auth.example.com"
cfg.CallbackURL = "/callback"
cfg.ClientID = "test-client"
cfg.ClientSecret = "test-client-secret"
cfg.SessionEncryptionKey = "test-encryption-key-32-bytes-long!!"
return cfg
}
const maxWholeSeconds = math.MaxInt64 / int64(time.Second)
func TestConfigValidateRejectsDurationOverflow(t *testing.T) {
cases := map[string]func(c *Config){
"maxTokenAgeSeconds wraps negative": func(c *Config) { c.MaxTokenAgeSeconds = maxWholeSeconds + 1 },
"maxTokenAgeSeconds wraps to zero": func(c *Config) { c.MaxTokenAgeSeconds = 1 << 55 },
"bearerFailureWindowSeconds": func(c *Config) { c.BearerFailureWindowSeconds = int(maxWholeSeconds + 1) },
"bearerFailurePenaltySeconds": func(c *Config) { c.BearerFailurePenaltySeconds = int(maxWholeSeconds + 1) },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
cfg := boundsTestConfig()
mutate(cfg)
if err := cfg.Validate(); err == nil {
t.Fatal("Validate accepted an overflowing duration")
}
})
}
t.Run("max exact value still valid", func(t *testing.T) {
cfg := boundsTestConfig()
cfg.MaxTokenAgeSeconds = maxWholeSeconds
if err := cfg.Validate(); err != nil {
t.Fatalf("boundary value rejected: %v", err)
}
})
}
func TestRedisConfigValidateRejectsOverflowAndHugePool(t *testing.T) {
base := func() *RedisConfig {
rc := &RedisConfig{Enabled: true, Address: "localhost:6379"}
return rc
}
cases := map[string]func(rc *RedisConfig){
"healthCheckInterval wraps to zero": func(rc *RedisConfig) { rc.HealthCheckInterval = 1 << 55 },
"circuitBreakerTimeout wraps": func(rc *RedisConfig) { rc.CircuitBreakerTimeout = int(maxWholeSeconds + 1) },
"circuitBreakerThreshold narrows": func(rc *RedisConfig) { rc.CircuitBreakerThreshold = math.MaxInt32 + 1 },
"poolSize absurd": func(rc *RedisConfig) { rc.PoolSize = 1_000_000_000 },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
rc := base()
mutate(rc)
if err := rc.Validate(); err == nil {
t.Fatal("Validate accepted an unsafe value")
}
})
}
t.Run("defaults and sane values stay valid", func(t *testing.T) {
rc := base()
rc.PoolSize, rc.CircuitBreakerThreshold, rc.HealthCheckInterval = 50, 5, 30
if err := rc.Validate(); err != nil {
t.Fatalf("sane config rejected: %v", err)
}
})
}
+69
View File
@@ -0,0 +1,69 @@
package traefikoidc
import (
"testing"
"time"
)
func newNSCache(t *testing.T) *UniversalCache {
t.Helper()
c := NewUniversalCache(UniversalCacheConfig{Type: CacheTypeToken, MaxSize: 100, DefaultTTL: time.Hour, CleanupInterval: time.Hour})
t.Cleanup(func() { _ = c.Close() })
return c
}
// A verdict stored by one middleware instance must not satisfy another
// instance that verifies against a different client or audience.
func TestTokenCacheIsolatedPerInstanceNamespace(t *testing.T) {
shared := newNSCache(t)
a := &TokenCache{cache: shared}
b := &TokenCache{cache: shared}
a.SetNamespace("https://idp|app-a|app-a")
b.SetNamespace("https://idp|app-b|app-b")
a.Set("tok", map[string]interface{}{"sub": "u"}, time.Minute)
if _, ok := b.Get("tok"); ok {
t.Fatal("instance B hit a verdict cached by instance A")
}
if _, ok := a.Get("tok"); !ok {
t.Fatal("instance A lost its own verdict")
}
a.Delete("tok")
if _, ok := a.Get("tok"); ok {
t.Fatal("Delete did not remove the namespaced entry")
}
}
func TestTokenCacheNamespaceFollowsDCR(t *testing.T) {
shared := newNSCache(t)
tc := &TokenCache{cache: shared}
tc.SetNamespace("p|old|old")
tc.Set("tok", map[string]interface{}{"sub": "u"}, time.Minute)
tc.SetNamespace("p|new|new")
if _, ok := tc.Get("tok"); ok {
t.Fatal("verdict cached under the old client survived a namespace change")
}
}
func TestTokenCacheEmptyNamespaceKeepsLegacyKey(t *testing.T) {
shared := newNSCache(t)
a := &TokenCache{cache: shared}
b := &TokenCache{cache: shared}
a.Set("tok", map[string]interface{}{"sub": "u"}, time.Minute)
if _, ok := b.Get("tok"); !ok {
t.Fatal("namespace-less caches must keep sharing the legacy key")
}
}
func TestIntrospectionKeyScopedPerInstance(t *testing.T) {
a := &TraefikOidc{tokenCache: &TokenCache{}}
b := &TraefikOidc{tokenCache: &TokenCache{}}
a.tokenCache.SetNamespace("p|app-a|app-a")
b.tokenCache.SetNamespace("p|app-b|app-b")
if a.introspectionKey("tok") == b.introspectionKey("tok") {
t.Fatal("different instances share an introspection cache key")
}
if got := (&TraefikOidc{}).introspectionKey("tok"); got != "tok" {
t.Fatalf("namespace-less key = %q, want raw token", got)
}
}
+11 -2
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(token); found {
if cached, found := t.introspectionCache.Get(t.introspectionKey(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(token, &introspectionResp, cacheDuration)
t.introspectionCache.Set(t.introspectionKey(token), &introspectionResp, cacheDuration)
t.logger.Debugf("Cached introspection result for %v", cacheDuration)
}
@@ -280,3 +280,12 @@ func (t *TraefikOidc) validateOpaqueToken(token string) error {
t.logger.Debugf("Opaque token validation successful via introspection")
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(token)
t.introspectionCache.Delete(t.introspectionKey(token))
}
d := blacklistDuration(token)
+2 -2
View File
@@ -15,6 +15,7 @@ package traefikoidc
import (
"encoding/base64"
"encoding/json"
"errors"
"strings"
"time"
)
@@ -266,8 +267,7 @@ func (t *TraefikOidc) validateStandardTokensRS(rs *requestState) (bool, bool, bo
var accessVerifyErr error
if err := t.verifyToken(rs.accessToken); err != nil {
accessVerifyErr = err
errMsg := err.Error()
if strings.Contains(errMsg, "invalid audience") || strings.Contains(errMsg, "audience") {
if errors.Is(err, errInvalidAudience) {
if t.strictAudienceValidation {
if rs.refreshToken != "" {
return false, true, false
+38 -2
View File
@@ -380,6 +380,10 @@ 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
@@ -712,6 +716,9 @@ 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)
@@ -915,11 +922,29 @@ func (c *UniversalCache) evictOldest() {
}
}
// rejectOversized drops a value larger than the whole memory cap instead of
// flushing every other entry to fit it. A stale copy under the key is removed.
// Caller holds c.mu.
func (c *UniversalCache) rejectOversized(key string, size int64) bool {
if c.config.MaxMemoryBytes <= 0 || size <= c.config.MaxMemoryBytes {
return false
}
if existing, ok := c.items[key]; ok {
c.removeItem(key, existing)
}
c.logger.Infof("UniversalCache[%s]: dropped oversized value key=%s size=%d cap=%d",
c.config.Type, key, size, c.config.MaxMemoryBytes)
return true
}
// SetMaxSize sets the maximum size and evicts items if necessary
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
@@ -1021,6 +1046,9 @@ func (c *UniversalCache) estimateSize(value interface{}) int64 {
}
}
// graceOriginKey holds the item's first expiry once grace has started.
const graceOriginKey = "grace_origin"
// shouldExtendGracePeriod determines if grace period should be extended
func (c *UniversalCache) shouldExtendGracePeriod(item *CacheItem, now time.Time) bool {
if c.config.MetadataConfig == nil {
@@ -1040,8 +1068,12 @@ func (c *UniversalCache) shouldExtendGracePeriod(item *CacheItem, now time.Time)
}
}
// Calculate how long since the item originally expired
timeSinceExpiry := now.Sub(item.ExpiresAt)
// 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)
return timeSinceExpiry <= maxGrace
}
@@ -1523,6 +1555,10 @@ 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
@@ -0,0 +1,88 @@
package traefikoidc
import (
"math"
"testing"
"time"
)
func newBoundsCache(t *testing.T, cfg UniversalCacheConfig) *UniversalCache {
t.Helper()
cfg.CleanupInterval = time.Hour
if cfg.DefaultTTL == 0 {
cfg.DefaultTTL = time.Hour
}
c := NewUniversalCache(cfg)
t.Cleanup(func() { _ = c.Close() })
return c
}
// Grace extensions must stay within MaxGracePeriod of the ORIGINAL expiry.
func TestGraceNeverServesPastOriginalExpiryBound(t *testing.T) {
c := newBoundsCache(t, UniversalCacheConfig{
Type: CacheTypeMetadata, MaxSize: 10,
MetadataConfig: &MetadataCacheConfig{
GracePeriod: 20 * time.Millisecond, ExtendedGracePeriod: 20 * time.Millisecond,
MaxGracePeriod: 100 * time.Millisecond, SecurityCriticalMaxGracePeriod: 100 * time.Millisecond,
},
})
if err := c.Set("k", "v", 10*time.Millisecond); err != nil {
t.Fatal(err)
}
c.ActivateGracePeriod("k")
deadline := time.Now().Add(600 * time.Millisecond)
for time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
if _, ok := c.Get("k"); !ok {
return
}
}
t.Fatal("stale entry still served 600ms after a 100ms max grace")
}
func TestSetMaxSizeNegativeTerminates(t *testing.T) {
c := newBoundsCache(t, UniversalCacheConfig{Type: CacheTypeGeneral, MaxSize: 10})
for _, k := range []string{"a", "b", "c"} {
_ = c.Set(k, "v", time.Hour)
}
done := make(chan struct{})
go func() { c.SetMaxSize(-1); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("SetMaxSize(-1) did not return")
}
}
// One oversized value must not flush the other live entries.
func TestOversizedValueDoesNotFlushCache(t *testing.T) {
c := newBoundsCache(t, UniversalCacheConfig{
Type: CacheTypeGeneral, MaxSize: 10, MaxMemoryBytes: 1000, EnableMemoryLimit: true,
})
_ = c.Set("live", "x", time.Hour)
_ = c.Set("big", make([]byte, 5000), time.Hour)
if _, ok := c.Get("live"); !ok {
t.Fatal("oversized insert evicted a live entry")
}
c.mu.RLock()
mem := c.currentMemory
c.mu.RUnlock()
if mem > 1000 {
t.Fatalf("currentMemory=%d exceeds cap 1000", mem)
}
}
// Out-of-range or non-finite nbf/iat must be rejected, not wrapped into the past.
func TestTimeClaimsRejectOutOfRange(t *testing.T) {
for _, v := range []float64{1e19, 1e300, math.Inf(1), math.Inf(-1), math.NaN()} {
if verifyNotBefore(v) == nil {
t.Errorf("nbf=%v accepted", v)
}
if verifyIssuedAt(v) == nil {
t.Errorf("iat=%v accepted", v)
}
if verifyExpiration(v) == nil {
t.Errorf("exp=%v accepted", v)
}
}
}
+13 -3
View File
@@ -453,6 +453,15 @@ 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.
@@ -462,13 +471,14 @@ func sameHost(a, b string) bool {
// Returns:
// - An error if the host is dangerous or not allowed, nil if safe.
func (t *TraefikOidc) validateHost(host string) error {
hostname := host
if strings.Contains(host, ":") {
hostname := canonicalHost(host)
if net.ParseIP(hostname) == nil && strings.Contains(hostname, ":") {
var err error
hostname, _, err = net.SplitHostPort(host)
hostname, _, err = net.SplitHostPort(hostname)
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 = "0.0.0-dev"
const traefikoidcPluginVersion = "1.0.62"