Compare commits

...
22 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
lukaszraczyloandGitHub 9ab7220828 fix(security): close confirmed findings from the 2026-10-15 hunt (#166)
Seven findings are fixed. Each fix has a regression test.

- Compare X-Forwarded-Host and the serving host case-insensitively, so a
  case variant cannot widen the session cookie Domain.
- Reject a future iat on the opaque introspection path.
- Serve the cached parsed JWKS inside the signature-refresh cooldown.
- Release the half-open circuit slot when a probe panics.
- Cap requests that wait for provider initialisation at 512. Extra
  requests receive 503 with Retry-After.
- Cap cookie deletion headers at 32 cookie names per response.
- Cap front-channel sid markers at 1000, so unauthenticated requests
  cannot evict backchannel or jti replay markers.
2026-10-16 00:47:35 +01:00
lukaszraczyloandGitHub f0af6282dc feat(templates): add toJson helper for object claims (#165)
Header templates can now emit an object or array claim as one JSON
header. Example: {{get .Claims "tenant" | toJson}}. The backend parses
the claim, so the middleware does not need to know its schema.

The helper adds no new access path. The validator treats toJson like
default and checks its arguments. A claim must be on the whitelist, and
a dump of the whole claims map is rejected. An absent or empty claim
skips the header. The JSON encoder escapes control characters.

A rendered header value over 8192 bytes is still dropped. The drop is
now logged at error level, with the header name and without the value.
Other sanitizer drops stay at debug level.

Tests cover value types, edge cases, whitelist bypass attempts, token
leaks and header injection. A new yaegi check runs the helper under the
interpreter. README, configuration docs and the plugin manifest show the
new helper.
2026-10-05 00:20:01 +01:00
lukaszraczylo 7c5a86f4e1 chore: remove dependabot config 2026-10-04 01:04:57 +01:00
lukaszraczyloandGitHub 94a8664403 perf(session): cut yaegi allocations on authenticated requests (#164)
Under yaegi, the authenticated cookie path allocated about 3.8 MB per
request. A yaegi harness measures bytes per request with a browser-like
cookie jar. With it, these changes cut bytes by 83%, allocations by 69%
and time by about 93%.

- Replace range loops over tokens with index loops. Under yaegi, ranging
  over a string allocates O(n^2) bytes.
- Pool gzip readers for combined session cookies.
- Memoize the validated ID token within one request.
- Cache content-only token validation across requests. The key is the
  token type and SHA-256, and the cache holds at most 4096 entries. The
  entry keeps the parsed exp and iat claims. The expiry and freshness
  checks still run on every request.

New tests pin the validator results, the memo and the cache behavior.
2026-09-29 11:59:31 +01:00
54 changed files with 2381 additions and 174 deletions
-52
View File
@@ -1,52 +0,0 @@
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,6 +72,13 @@ 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:
+28 -2
View File
@@ -441,8 +441,34 @@ 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`
are the only functions allowed.
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.
> **File-provider users: escape the braces.** Traefik's file provider runs
> every dynamic configuration file through Go templating before the plugin
+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)
}
}
}
+12 -3
View File
@@ -223,6 +223,9 @@ 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
@@ -230,7 +233,7 @@ func headerInjectionRuneReason(r rune) string {
// never includes the value, so it is safe to log.
func headerValueReason(value string, maxLen int) string {
if maxLen > 0 && len(value) > maxLen {
return "exceeds max length"
return headerReasonTooLong
}
for _, r := range value {
if reason := headerInjectionRuneReason(r); reason != "" {
@@ -368,6 +371,11 @@ 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
}
@@ -550,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)
}
}
+94
View File
@@ -0,0 +1,94 @@
package traefikoidc
import (
"strings"
"testing"
)
// These tests pin the token-content validators' results, including for
// multi-byte UTF-8 input, so their loops can be rewritten for yaegi (where
// ranging over a string allocates O(n²)) without changing behavior.
func TestDetectRepeatedCharactersPinned(t *testing.T) {
cm := NewChunkManager(NewLogger(""))
cases := []struct {
name string
token string
wantErr string
}{
{"short token skipped", "aaaaaaaaa", ""},
{"varied ascii", "abcdefghijklmnopqrstuvwxyz0123456789", ""},
{"20 repeats allowed", strings.Repeat("a", 20) + strings.Repeat("bcdefghij", 4), ""},
{"21 repeats rejected", strings.Repeat("a", 21) + strings.Repeat("bcdefghij", 4), "excessive repeated characters (21 consecutive)"},
{"multibyte run counted per rune", strings.Repeat("é", 21) + strings.Repeat("bcdefghij", 4), "excessive repeated characters (21 consecutive)"},
// Rune counts are divided by the byte length, so a multi-byte rune
// cannot reach the 70% threshold. Pinned as-is.
{"multibyte frequency divides by byte length", strings.Repeat("éééx", 8), ""},
{"ascii frequency over 70%", strings.Repeat("aaaab", 8), "suspicious character frequency (char 'a'"},
{"multibyte mixed passes", "日本語テキストabcdefghijklmnopqrstuvwxyz", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := cm.detectRepeatedCharacters(tc.token, AccessTokenConfig)
assertPinnedErr(t, err, tc.wantErr)
})
}
}
func TestValidateJWTFormatPinned(t *testing.T) {
cm := NewChunkManager(NewLogger(""))
cases := []struct {
name string
token string
wantErr string
}{
{"valid base64url parts", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2lnbmF0dXJlLXZhbHVl"), ""},
{"padding allowed", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0=", "c2lnbmF0dXJl"), ""},
{"plus rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzd+IiOiJ4In0", "c2lnbmF0dXJl"), "invalid base64url character in part 1"},
{"multibyte rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdé", "c2lnbmF0dXJl"), "invalid base64url character in part 1"},
{"control char rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2ln\x01bmF0"), "invalid base64url character in part 2"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := cm.validateJWTFormat(tc.token, "access")
assertPinnedErr(t, err, tc.wantErr)
})
}
}
func TestValidateTokenSanitizationPinned(t *testing.T) {
cm := NewChunkManager(NewLogger(""))
cases := []struct {
name string
token string
wantErr string
}{
{"clean jwt", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2lnbmF0dXJl"), ""},
{"multibyte allowed", jwtLike("eyJhbGciOiJSUzI1NiJ9", "日本語", "c2lnbmF0dXJl"), ""},
{"control char byte offset", "abcé\x01def", "control character at position 5"},
{"delete char", "abcdef\x7fgh", "control character at position 6"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := cm.validateTokenSanitization(tc.token, AccessTokenConfig)
assertPinnedErr(t, err, tc.wantErr)
})
}
}
// jwtLike joins parts with dots at runtime; JWT-shaped literals trip the
// pre-commit secret scanner.
func jwtLike(parts ...string) string { return strings.Join(parts, ".") }
func assertPinnedErr(t *testing.T, err error, want string) {
t.Helper()
if want == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want containing %q", err, want)
}
}
+94
View File
@@ -139,6 +139,7 @@ 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")
}
@@ -807,6 +808,99 @@ 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
+45
View File
@@ -0,0 +1,45 @@
package traefikoidc
import (
"sync"
"testing"
)
func TestDecompressCombinedPayloadReusesReaders(t *testing.T) {
var readers sync.Pool
first, err := compressCombinedPayload(&combinedSessionPayload{Ui: "[email protected]"})
if err != nil {
t.Fatalf("compress first: %v", err)
}
second, err := compressCombinedPayload(&combinedSessionPayload{Ui: "[email protected]"})
if err != nil {
t.Fatalf("compress second: %v", err)
}
cases := []struct {
name string
input string
wantUser string
wantErr bool
}{
{"first payload", first, "[email protected]", false},
{"corrupt payload rejected", "bm90LWd6aXA=", "", true},
{"second payload after corrupt one", second, "[email protected]", false},
{"first payload again", first, "[email protected]", false},
}
for _, tc := range cases {
got, err := decompressCombinedPayload(tc.input, &readers)
if tc.wantErr {
if err == nil {
t.Fatalf("%s: expected error", tc.name)
}
continue
}
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if got.Ui != tc.wantUser {
t.Fatalf("%s: user = %q, want %q", tc.name, got.Ui, tc.wantUser)
}
}
}
+67
View File
@@ -0,0 +1,67 @@
package traefikoidc
import (
"strings"
"testing"
"time"
)
func TestContentValidCache(t *testing.T) {
cm := NewChunkManager(NewLogger(""))
t.Cleanup(cm.Shutdown)
now := time.Now()
valid := tokenWithGroups(t, map[string]interface{}{"sub": "a", "exp": float64(now.Add(time.Hour).Unix()), "iat": float64(now.Unix())})
futureIat := tokenWithGroups(t, map[string]interface{}{"sub": "b", "exp": float64(now.Add(2 * time.Hour).Unix()), "iat": float64(now.Add(time.Hour).Unix())})
badContent := jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2ln\x01bmF0dXJlLXZhbHVl")
t.Run("valid token passes twice and is cached", func(t *testing.T) {
for i := 0; i < 2; i++ {
if res := cm.validateToken(valid, AccessTokenConfig); res.Error != nil {
t.Fatalf("pass %d: %v", i, res.Error)
}
}
if _, ok := cm.cachedTokenTimes(contentValidKey(valid, AccessTokenConfig)); !ok {
t.Fatal("valid token was not cached")
}
})
t.Run("cache is per token type", func(t *testing.T) {
if _, ok := cm.cachedTokenTimes(contentValidKey(valid, IDTokenConfig)); ok {
t.Fatal("access-token entry leaked into the ID-token key")
}
})
t.Run("time checks still run on a cache hit", func(t *testing.T) {
cm.markContentValid(contentValidKey(futureIat, AccessTokenConfig), cm.tokenTimesOf(futureIat))
res := cm.validateToken(futureIat, AccessTokenConfig)
if res.Error == nil || !strings.Contains(res.Error.Error(), "issued in future") {
t.Fatalf("error = %v, want issued-in-future through the content cache", res.Error)
}
})
t.Run("cache hit uses the stored claims", func(t *testing.T) {
other := tokenWithGroups(t, map[string]interface{}{"sub": "c", "exp": float64(now.Add(time.Hour).Unix()), "iat": float64(now.Unix())})
future := now.Add(time.Hour)
cm.markContentValid(contentValidKey(other, AccessTokenConfig), tokenTimes{jwt: true, iat: &future})
res := cm.validateToken(other, AccessTokenConfig)
if res.Error == nil || !strings.Contains(res.Error.Error(), "issued in future") {
t.Fatalf("error = %v, want the cached iat to drive the freshness check", res.Error)
}
})
t.Run("invalid content never cached", func(t *testing.T) {
if res := cm.validateToken(badContent, AccessTokenConfig); res.Error == nil {
t.Fatal("control character accepted")
}
if _, ok := cm.cachedTokenTimes(contentValidKey(badContent, AccessTokenConfig)); ok {
t.Fatal("invalid token was cached")
}
})
t.Run("cache stays bounded", func(t *testing.T) {
for i := 0; i < contentValidCacheMax+10; i++ {
cm.markContentValid(contentValidKey(strings.Repeat("x", i%7)+string(rune('a'+i%26))+strings.Repeat("y", i/26), AccessTokenConfig), tokenTimes{})
}
cm.contentMu.Lock()
n := len(cm.contentValid)
cm.contentMu.Unlock()
if n > contentValidCacheMax {
t.Fatalf("cache size %d exceeds %d", n, contentValidCacheMax)
}
})
}
+16 -1
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`, and `range`/`with` that do not
on the list, functions other than `get`/`default`/`toJson`, and `range`/`with` that do not
target a specific listed claim (e.g. `{{range .Claims}}` is rejected; `{{range
.Claims.groups}}` is allowed).
@@ -592,6 +592,21 @@ 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
@@ -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")
}
}
+238
View File
@@ -0,0 +1,238 @@
package traefikoidc
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestEnhanceSessionSecurity_CaseVariantForwardedHostStaysHostScoped(t *testing.T) {
cases := []struct {
name string
xfh string
}{
{name: "exact-case parent", xfh: "corp.example.com"},
{name: "upper-case parent", xfh: "CORP.example.com"},
{name: "mixed-case parent", xfh: "Corp.Example.COM"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sm := createTestSessionManager(t)
t.Cleanup(func() { _ = sm.Shutdown() })
req := httptest.NewRequest(http.MethodGet, "https://app.corp.example.com/", nil)
req.Host = "app.corp.example.com"
req.Header.Set("X-Forwarded-Host", tc.xfh)
opts := sm.EnhanceSessionSecurity(nil, req)
if opts.Domain != "app.corp.example.com" {
t.Fatalf("Domain = %q, want host-scoped app.corp.example.com", opts.Domain)
}
})
}
t.Run("case-variant forwarded host equal to serving host is kept", func(t *testing.T) {
sm := createTestSessionManager(t)
t.Cleanup(func() { _ = sm.Shutdown() })
req := httptest.NewRequest(http.MethodGet, "https://internal.svc/", nil)
req.Host = "internal.svc"
req.Header.Set("X-Forwarded-Host", "App.Example.com")
opts := sm.EnhanceSessionSecurity(nil, req)
if opts.Domain != "app.example.com" {
t.Fatalf("Domain = %q, want app.example.com", opts.Domain)
}
})
}
func TestEnforceIatAge_RejectsFutureIat(t *testing.T) {
const maxAge = 24 * time.Hour
now := time.Now()
cases := []struct {
name string
iat time.Time
wantErr bool
}{
{name: "now", iat: now},
{name: "within future skew", iat: now.Add(ClockSkewToleranceFuture / 2)},
{name: "one hour ahead", iat: now.Add(time.Hour), wantErr: true},
{name: "just beyond skew", iat: now.Add(ClockSkewToleranceFuture + 10*time.Second), wantErr: true},
{name: "older than max age", iat: now.Add(-48 * time.Hour), wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := enforceIatAge(map[string]interface{}{"iat": float64(tc.iat.Unix())}, maxAge)
if (err != nil) != tc.wantErr {
t.Fatalf("enforceIatAge err = %v, wantErr %v", err, tc.wantErr)
}
})
}
}
func TestJWKCache_CooldownServesParsedEntryWithoutReparse(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("keygen: %v", err)
}
c := NewJWKCache()
url := fmt.Sprintf("https://jwks.invalid/%d", time.Now().UnixNano())
// The raw set is empty, so only the pre-parsed entry can satisfy the lookup.
_ = c.cache.SetLocal(url, &JWKSet{}, time.Hour)
_ = c.cache.SetLocal(url+parsedKeysSuffix, &parsedJWKS{keys: map[string]crypto.PublicKey{"k1": &priv.PublicKey}}, time.Hour)
c.lastSignatureRefresh[url] = time.Now()
got, err := c.getPublicKeyFresh(context.Background(), url, "k1", http.DefaultClient)
if err != nil {
t.Fatalf("getPublicKeyFresh: %v", err)
}
if got != crypto.PublicKey(&priv.PublicKey) {
t.Fatal("cooldown branch did not serve the cached parsed key")
}
}
func TestFrontchannelLogout_FloodCannotEvictSignedMarkers(t *testing.T) {
cache := &CacheInterfaceWrapper{cache: NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeSession,
MaxSize: 5000,
DefaultTTL: sessionInvalidationTTL,
Logger: NewLogger("error"),
SkipAutoCleanup: true,
MonotonicMarkers: true,
})}
oidc := &TraefikOidc{
logger: NewLogger("error"),
enableFrontchannelLogout: true,
sessionInvalidationCache: cache,
issuerURL: "https://idp.example.com",
}
t.Cleanup(func() { _ = cache.cache.Close() })
if err := oidc.invalidateSession("", "real-subject"); err != nil {
t.Fatalf("invalidateSession: %v", err)
}
if err := oidc.checkAndMarkLogoutJTIProcessed("real-jti", time.Now().Unix()); err != nil {
t.Fatalf("mark jti: %v", err)
}
for i := 0; i < 6000; i++ {
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/fc?iss=https://idp.example.com&sid=flood-%d", i), nil)
rw := httptest.NewRecorder()
oidc.handleFrontchannelLogout(rw, req)
if rw.Code != http.StatusOK {
t.Fatalf("flood request %d: status %d", i, rw.Code)
}
}
if _, ok := cache.Get(oidc.buildSessionInvalidationKey("sub", "real-subject")); !ok {
t.Error("backchannel sub marker evicted by front-channel flood")
}
if err := oidc.checkAndMarkLogoutJTIProcessed("real-jti", time.Now().Unix()); err == nil {
t.Error("jti replay marker evicted by front-channel flood: replay accepted")
}
held := 0
for i := 0; i < 6000; i++ {
if _, ok := cache.Get(oidc.buildSessionInvalidationKey("sid", fmt.Sprintf("flood-%d", i))); ok {
held++
}
}
if held > maxFrontchannelMarkers {
t.Errorf("front-channel markers held = %d, want <= %d", held, maxFrontchannelMarkers)
}
if _, ok := cache.Get(oidc.buildSessionInvalidationKey("sid", "flood-5999")); !ok {
t.Error("most recent front-channel marker must remain")
}
}
func TestMarkerQuota_Admit(t *testing.T) {
var q markerQuota
if got := q.admit("a", 2); got != "" {
t.Errorf("admit a = %q, want none", got)
}
if got := q.admit("a", 2); got != "" {
t.Errorf("re-admit a = %q, want none", got)
}
if got := q.admit("b", 2); got != "" {
t.Errorf("admit b = %q, want none", got)
}
if got := q.admit("c", 2); got != "a" {
t.Errorf("admit c evicted %q, want a", got)
}
}
func TestServeHTTP_InitWaitQueueFullFailsFast(t *testing.T) {
oidc := &TraefikOidc{
logger: NewLogger("error"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
}
oidc.initWaiters = maxInitWaiters
rw := httptest.NewRecorder()
start := time.Now()
oidc.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/protected", nil))
if rw.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rw.Code)
}
if rw.Header().Get("Retry-After") == "" {
t.Error("missing Retry-After header")
}
if time.Since(start) > 5*time.Second {
t.Errorf("rejected after %v, want immediate", time.Since(start))
}
if oidc.initWaiters != maxInitWaiters {
t.Errorf("initWaiters = %d, want unchanged %d", oidc.initWaiters, maxInitWaiters)
}
}
func TestServeHTTP_InitWaiterReleasedOnCancel(t *testing.T) {
oidc := &TraefikOidc{
logger: NewLogger("error"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := httptest.NewRequest(http.MethodGet, "/protected", nil).WithContext(ctx)
oidc.ServeHTTP(httptest.NewRecorder(), req)
if oidc.initWaiters != 0 {
t.Fatalf("initWaiters = %d after canceled wait, want 0", oidc.initWaiters)
}
}
func TestCleanupOldCookies_CapsDeletionHeaders(t *testing.T) {
sm, err := NewSessionManager("test-encryption-key-32-bytes-long!!", false, "cookie.example.com", "testpref", 0, newNoOpLogger())
if err != nil {
t.Fatalf("session manager: %v", err)
}
t.Cleanup(func() { _ = sm.Shutdown() })
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "a.b.example.com"
for i := 0; i < 180; i++ {
req.AddCookie(&http.Cookie{Name: fmt.Sprintf("testpref_%d", i), Value: "x"})
}
rw := httptest.NewRecorder()
sm.CleanupOldCookies(rw, req)
got := len(rw.Result().Cookies())
if got == 0 {
t.Fatal("expected deletion cookies for the first wave")
}
if limit := maxCleanupCookies * 4; got > limit {
t.Fatalf("deletion Set-Cookie headers = %d, want <= %d", got, limit)
}
for _, c := range rw.Result().Cookies() {
if !strings.HasPrefix(c.Name, "testpref_") {
t.Errorf("unexpected deletion cookie %q", c.Name)
}
}
}
+50
View File
@@ -0,0 +1,50 @@
package traefikoidc
import (
"net/http/httptest"
"testing"
"time"
)
func TestIDTokenMemoFollowsStoredToken(t *testing.T) {
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef", false, "", "", time.Hour, NewLogger("error"))
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
t.Cleanup(func() {
if err := sm.Shutdown(); err != nil {
t.Errorf("Shutdown: %v", err)
}
})
future := float64(time.Now().Add(time.Hour).Unix())
first := tokenWithGroups(t, map[string]interface{}{"sub": "first", "exp": future})
second := tokenWithGroups(t, map[string]interface{}{"sub": "second", "exp": future})
session, err := sm.GetSession(httptest.NewRequest("GET", "/", nil))
if err != nil {
t.Fatalf("GetSession: %v", err)
}
steps := []struct {
name string
write func()
want string
}{
{"first read", func() { session.SetIDToken(first) }, first},
{"repeat read", func() {}, first},
{"after overwrite", func() { session.SetIDToken(second) }, second},
{"after clearing", func() { session.SetIDToken("") }, ""},
{"after Reset", func() { session.SetIDToken(first); _ = session.GetIDToken(); session.Reset() }, ""},
}
for _, st := range steps {
st.write()
if got := session.GetIDToken(); got != st.want {
t.Fatalf("%s: GetIDToken = %q, want %q", st.name, got, st.want)
}
if st.name == "repeat read" && session.idTokenMemoRaw != first {
t.Fatal("repeat read did not go through the memo; the test no longer covers it")
}
}
if session.idTokenMemoRaw != "" || session.idTokenMemo != "" {
t.Fatal("Reset left an ID-token memo on the pooled session")
}
}
+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())
}
}
+9 -1
View File
@@ -93,8 +93,16 @@ func (cb *CircuitBreaker) ExecuteWithContext(ctx context.Context, fn func() erro
return fmt.Errorf("circuit breaker is open")
}
// Execute the function
// A panicking fn must still settle the breaker, otherwise a half-open
// probe slot leaks until the next open->half-open transition.
panicked := true
defer func() {
if panicked {
cb.recordFailure()
}
}()
err := fn()
panicked = false
if err != nil {
cb.recordFailure()
@@ -0,0 +1,40 @@
package recovery
import (
"context"
"errors"
"testing"
"time"
)
func TestCircuitBreaker_PanickingProbeReleasesHalfOpenSlot(t *testing.T) {
cb := NewCircuitBreaker(CircuitBreakerConfig{
FailureThreshold: 1,
SuccessThreshold: 1,
Timeout: 20 * time.Millisecond,
MaxRequests: 1,
}, &mockLogger{})
_ = cb.ExecuteWithContext(context.Background(), func() error { return errors.New("fail") })
time.Sleep(40 * time.Millisecond)
func() {
defer func() {
if recover() == nil {
t.Error("panic must still propagate to the caller")
}
}()
_ = cb.ExecuteWithContext(context.Background(), func() error { panic("boom") })
}()
if cb.GetState() != CircuitBreakerOpen {
t.Fatalf("state after panicking probe = %s, want open", cb.GetState())
}
time.Sleep(40 * time.Millisecond)
if err := cb.ExecuteWithContext(context.Background(), func() error { return nil }); err != nil {
t.Fatalf("probe after next timeout rejected: %v", err)
}
if cb.GetState() != CircuitBreakerClosed {
t.Fatalf("state = %s, want closed", cb.GetState())
}
}
+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
}
+7
View File
@@ -389,6 +389,13 @@ 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 {
+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")
}
}
+60
View File
@@ -51,8 +51,43 @@ 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 {
@@ -191,6 +226,10 @@ 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
@@ -578,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)
}
@@ -600,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)
}
}
+21 -1
View File
@@ -7,6 +7,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -85,8 +86,10 @@ 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 two helpers:
// config.AllowedClaims). It exposes exactly three 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
@@ -102,6 +105,16 @@ 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 ""
@@ -535,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()
@@ -931,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
@@ -1070,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
}
+44 -5
View File
@@ -158,6 +158,15 @@ 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.
@@ -701,6 +710,13 @@ 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)
@@ -708,6 +724,7 @@ 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
@@ -744,10 +761,12 @@ 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
@@ -1035,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 {
@@ -1128,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
@@ -1324,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 {
@@ -1342,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)
@@ -1371,7 +1406,11 @@ 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 != "" {
t.logger.Debugf("Dropping templated header %s: value failed sanitization (%s)", headerName, 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)
}
continue
}
req.Header.Set(headerName, headerValue)
+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")
}
}
+57 -8
View File
@@ -116,6 +116,10 @@ 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.
//
@@ -193,8 +197,10 @@ func compressCombinedPayload(payload *combinedSessionPayload) (string, error) {
}
// decompressCombinedPayload decompresses a base64+gzip encoded combined session payload.
// readers pools *gzip.Reader values: a new reader allocates its 32 KB window,
// which is a large per-request cost under yaegi.
// Returns the deserialized payload and any error encountered.
func decompressCombinedPayload(compressed string) (*combinedSessionPayload, error) {
func decompressCombinedPayload(compressed string, readers *sync.Pool) (*combinedSessionPayload, error) {
if compressed == "" {
return nil, fmt.Errorf("empty compressed data")
}
@@ -204,11 +210,16 @@ func decompressCombinedPayload(compressed string) (*combinedSessionPayload, erro
return nil, fmt.Errorf("failed to decode base64: %w", err)
}
gr, err := gzip.NewReader(bytes.NewReader(data))
gr, _ := readers.Get().(*gzip.Reader)
if gr == nil {
gr, err = gzip.NewReader(bytes.NewReader(data))
} else {
err = gr.Reset(bytes.NewReader(data))
}
if err != nil {
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
}
defer func() { _ = gr.Close() }()
defer readers.Put(gr)
// Limit decompressed size to prevent zip bombs
limitedReader := io.LimitReader(gr, 512*1024) // 512KB max
@@ -408,6 +419,7 @@ func decompressTokenInternal(compressed string) string {
// session object reuse and supports both HTTP and HTTPS schemes.
type SessionManager struct {
sessionPool sync.Pool
gzipReaders sync.Pool
ctx context.Context
store sessions.Store
logger *Logger
@@ -929,6 +941,9 @@ 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
}
@@ -1016,6 +1031,7 @@ 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
@@ -1029,7 +1045,8 @@ func (sm *SessionManager) CleanupOldCookies(w http.ResponseWriter, r *http.Reque
shouldCleanup := currentDomain != "" && !sm.cleanupDone
sm.cleanupMutex.RUnlock()
if shouldCleanup {
if shouldCleanup && deletedCookies < maxCleanupCookies {
deletedCookies++
for _, domain := range domainsToClean {
if domain == currentDomain || domain == "."+currentDomain || "."+domain == currentDomain {
continue
@@ -1265,7 +1282,7 @@ func (sm *SessionManager) loadFromCombinedCookies(r *http.Request, sessionData *
return false
}
payload, err := decompressCombinedPayload(compressed)
payload, err := decompressCombinedPayload(compressed, &sm.gzipReaders)
if err != nil {
sm.logger.Debugf("Failed to decompress combined payload: %v", err)
return false
@@ -1410,6 +1427,13 @@ type SessionData struct {
// timed against the new owner's own writes.
sessionOwner atomic.Uint64
// idTokenMemoRaw and idTokenMemo cache getIDTokenUnsafe's validated result
// for an unchunked, uncompressed stored token, keyed by the stored value.
// A request reads the ID token twice, and each read re-ran full chunk
// validation. Cleared by Reset so no result outlives its request.
idTokenMemoRaw string
idTokenMemo string
// cachedClaimsToken is the ID token string whose claims were last parsed and
// cached. A lazy, per-request cache to avoid re-parsing the JWT on every
// authenticated request (e.g. for headerTemplates). Protected by sessionMutex.
@@ -2086,6 +2110,8 @@ func (sd *SessionData) Reset() {
sd.cachedClaimsToken = ""
sd.cachedClaims = nil
sd.cachedClaimsErr = nil
sd.idTokenMemoRaw = ""
sd.idTokenMemo = ""
// Reset the refresh mutex to ensure clean state
// Note: We don't need to lock it since sessionMutex is already held
@@ -2276,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()
@@ -2911,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.
@@ -2919,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
@@ -3017,6 +3058,11 @@ func (sd *SessionData) getIDTokenUnsafe() string {
return token
}
memoable := token != "" && !compressed && len(sd.idTokenChunks) == 0
if memoable && token == sd.idTokenMemoRaw {
return sd.idTokenMemo
}
result := sd.manager.chunkManager.GetToken(
token,
compressed,
@@ -3024,11 +3070,14 @@ func (sd *SessionData) getIDTokenUnsafe() string {
IDTokenConfig,
)
validated := result.Token
if result.Error != nil {
return ""
validated = ""
}
return result.Token
if memoable {
sd.idTokenMemoRaw, sd.idTokenMemo = token, validated
}
return validated
}
// getRefreshTokenUnsafe retrieves the refresh token without acquiring locks.
+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)
}
}
+125 -42
View File
@@ -10,6 +10,7 @@ import (
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/gorilla/sessions"
"github.com/lukaszraczylo/traefikoidc/internal/pool"
@@ -89,9 +90,14 @@ type TokenRetrievalResult struct {
// and error handling to ensure data integrity and prevent security vulnerabilities
// throughout the process.
type ChunkManager struct {
lastCleanup time.Time
ctx context.Context
mutex *sync.RWMutex
lastCleanup time.Time
ctx context.Context
mutex *sync.RWMutex
// contentValid holds keys of tokens that passed every content-only check.
// Browsers resend the same cookie, and re-running those checks on every
// request dominated per-request cost under yaegi. Guarded by contentMu.
contentValid map[string]tokenTimes
contentMu sync.Mutex
cancel context.CancelFunc
sessionMap map[string]*SessionEntry
logger *Logger
@@ -127,14 +133,15 @@ func NewChunkManager(logger *Logger) *ChunkManager {
ctx, cancel := context.WithCancel(context.Background())
cm := &ChunkManager{
logger: logger,
mutex: &sync.RWMutex{},
ctx: ctx,
cancel: cancel,
sessionMap: make(map[string]*SessionEntry),
maxSessions: 200, // CRITICAL FIX: Reduced from 1000 to 200 per instance
sessionTTL: 15 * time.Minute, // CRITICAL FIX: Reduced from 24h to 15 minutes
lastCleanup: time.Now(),
logger: logger,
mutex: &sync.RWMutex{},
ctx: ctx,
cancel: cancel,
sessionMap: make(map[string]*SessionEntry),
contentValid: make(map[string]tokenTimes),
maxSessions: 200, // CRITICAL FIX: Reduced from 1000 to 200 per instance
sessionTTL: 15 * time.Minute, // CRITICAL FIX: Reduced from 24h to 15 minutes
lastCleanup: time.Now(),
}
// Start background cleanup routine
@@ -293,26 +300,38 @@ func (cm *ChunkManager) processSingleToken(token string, compressed bool, config
// Returns:
// - TokenRetrievalResult with the validated token or validation error.
func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRetrievalResult {
if sizeErr := cm.validateTokenSize(token, config); sizeErr != nil {
return TokenRetrievalResult{Token: "", Error: sizeErr}
// Content-only checks depend on the token string alone, so a token that
// passed them once skips them. Expiry and freshness always run.
key := contentValidKey(token, config)
times, cached := cm.cachedTokenTimes(key)
if !cached {
if sizeErr := cm.validateTokenSize(token, config); sizeErr != nil {
return TokenRetrievalResult{Token: "", Error: sizeErr}
}
cm.validateChunkingEfficiency(token, config)
if contentErr := cm.validateTokenContent(token, config); contentErr != nil {
return TokenRetrievalResult{Token: "", Error: contentErr}
}
times = cm.tokenTimesOf(token)
}
cm.validateChunkingEfficiency(token, config)
if contentErr := cm.validateTokenContent(token, config); contentErr != nil {
return TokenRetrievalResult{Token: "", Error: contentErr}
}
if expErr := cm.validateTokenExpiration(token, config); expErr != nil {
if expErr := cm.validateTokenExpiration(times, config); expErr != nil {
return TokenRetrievalResult{Token: "", Error: expErr}
}
if freshnessErr := cm.validateTokenFreshness(token, config); freshnessErr != nil {
if freshnessErr := cm.validateTokenFreshness(times, config); freshnessErr != nil {
return TokenRetrievalResult{Token: "", Error: freshnessErr}
}
// Determine if token is opaque or JWT based on format
// JWT tokens have exactly 2 dots (3 parts: header.payload.signature)
if cached {
return TokenRetrievalResult{Token: token, Error: nil}
}
dotCount := strings.Count(token, ".")
isJWT := dotCount == 2
@@ -338,6 +357,7 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet
}
}
cm.markContentValid(key, times)
return TokenRetrievalResult{Token: token, Error: nil}
}
@@ -350,6 +370,53 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet
//
// Returns:
// - TokenRetrievalResult with the reassembled token or error.
//
// tokenTimes holds a token's parsed exp and iat claims. They are part of the
// token content, so a content-cache entry keeps them to avoid re-parsing the
// payload on every request. The checks that use them still run each time.
type tokenTimes struct {
exp, iat *time.Time
expErr, iatErr error
jwt bool
}
// tokenTimesOf parses exp and iat from a JWT-shaped token.
func (cm *ChunkManager) tokenTimesOf(token string) tokenTimes {
if !strings.Contains(token, ".") {
return tokenTimes{}
}
t := tokenTimes{jwt: true}
t.exp, t.expErr = cm.extractJWTExpiration(token)
t.iat, t.iatErr = cm.extractJWTIssuedAt(token)
return t
}
// contentValidCacheMax bounds contentValid; the map is cleared when full.
const contentValidCacheMax = 4096
// contentValidKey keys a token by type and SHA-256 so the cache does not
// retain token values.
func contentValidKey(token string, config TokenConfig) string {
sum := sha256.Sum256([]byte(token))
return config.Type + ":" + string(sum[:])
}
func (cm *ChunkManager) cachedTokenTimes(key string) (tokenTimes, bool) {
cm.contentMu.Lock()
t, ok := cm.contentValid[key]
cm.contentMu.Unlock()
return t, ok
}
func (cm *ChunkManager) markContentValid(key string, t tokenTimes) {
cm.contentMu.Lock()
if len(cm.contentValid) >= contentValidCacheMax {
cm.contentValid = make(map[string]tokenTimes)
}
cm.contentValid[key] = t
cm.contentMu.Unlock()
}
func (cm *ChunkManager) processChunkedToken(chunks map[int]*sessions.Session, config TokenConfig) TokenRetrievalResult {
if len(chunks) > config.MaxChunks {
err := fmt.Errorf("too many %s token chunks (%d, max: %d)", config.Type, len(chunks), config.MaxChunks)
@@ -378,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 {
@@ -504,7 +572,9 @@ func (cm *ChunkManager) validateJWTFormat(token string, tokenType string) error
return err
}
for _, char := range part {
// Byte loop: a multi-byte rune fails on its first byte either way.
for j := 0; j < len(part); j++ {
char := part[j]
if !((char >= 'A' && char <= 'Z') ||
(char >= 'a' && char <= 'z') ||
(char >= '0' && char <= '9') ||
@@ -717,9 +787,10 @@ func (cm *ChunkManager) validateTokenSanitization(token string, config TokenConf
return err
}
// Check for control characters (ASCII 0-31 and 127)
for i, char := range token {
if char < 32 || char == 127 {
// Check for control characters (ASCII 0-31 and 127). Byte loop: bytes of
// a multi-byte rune are >= 0x80, and i is a byte offset either way.
for i := 0; i < len(token); i++ {
if char := token[i]; char < 32 || char == 127 {
err := fmt.Errorf("%s token contains control character at position %d", config.Type, i)
return err
}
@@ -921,7 +992,13 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi
currentRepeated := 1
var lastChar rune
for i, char := range token {
// Index loops, not range: under yaegi, ranging over a string allocates
// O(n²) bytes, which dominated per-request cost for 1-2 KB tokens.
for i := 0; i < len(token); {
char, size := rune(token[i]), 1
if char >= utf8.RuneSelf {
char, size = utf8.DecodeRuneInString(token[i:])
}
if i > 0 && char == lastChar {
currentRepeated++
if currentRepeated > maxRepeated {
@@ -931,6 +1008,7 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi
currentRepeated = 1
}
lastChar = char
i += size
}
threshold := 20
@@ -941,8 +1019,13 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi
}
charFreq := make(map[rune]int)
for _, char := range token {
for i := 0; i < len(token); {
char, size := rune(token[i]), 1
if char >= utf8.RuneSelf {
char, size = utf8.DecodeRuneInString(token[i:])
}
charFreq[char]++
i += size
}
tokenLen := len(token)
@@ -963,21 +1046,21 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi
// It extracts and checks JWT expiration claims to ensure tokens are not expired
// and detects tokens with suspicious expiration times.
// Parameters:
// - token: The token to check expiration for.
// - times: The token's parsed claims (see tokenTimesOf).
// - config: Token configuration for error context.
//
// Returns:
// - An error if the token is expired or has invalid expiration, nil if valid.
//
//nolint:unparam // error return kept for API consistency and future use
func (cm *ChunkManager) validateTokenExpiration(token string, config TokenConfig) error {
if !strings.Contains(token, ".") {
func (cm *ChunkManager) validateTokenExpiration(times tokenTimes, config TokenConfig) error {
if !times.jwt {
return nil
}
expiration, err := cm.extractJWTExpiration(token)
if err != nil {
cm.logger.Debugf("Could not extract expiration from %s token: %v", config.Type, err)
expiration := times.exp
if times.expErr != nil {
cm.logger.Debugf("Could not extract expiration from %s token: %v", config.Type, times.expErr)
return nil
}
@@ -1062,19 +1145,19 @@ func (cm *ChunkManager) extractJWTExpiration(token string) (*time.Time, error) {
// It examines the 'iat' (issued at) claim to detect tokens issued too far
// in the future or suspiciously old tokens that might indicate replay attacks.
// Parameters:
// - token: The token to check freshness for.
// - times: The token's parsed claims (see tokenTimesOf).
// - config: Token configuration for error context.
//
// Returns:
// - An error if the token freshness is suspicious, nil if acceptable.
func (cm *ChunkManager) validateTokenFreshness(token string, config TokenConfig) error {
if !strings.Contains(token, ".") {
func (cm *ChunkManager) validateTokenFreshness(times tokenTimes, config TokenConfig) error {
if !times.jwt {
return nil
}
issuedAt, err := cm.extractJWTIssuedAt(token)
if err != nil {
cm.logger.Debugf("Could not extract issued time from %s token: %v", config.Type, err)
issuedAt := times.iat
if times.iatErr != nil {
cm.logger.Debugf("Could not extract issued time from %s token: %v", config.Type, times.iatErr)
return nil
}
+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)
}
})
}
+5 -2
View File
@@ -22,6 +22,7 @@ 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
@@ -183,12 +184,14 @@ func (v *templateValidator) validateCommand(cmd *parse.CommandNode, dotIsRoot bo
return nil
}
// validateFunctionCall allows only get/default. get's map argument must be a
// validateFunctionCall allows only get/default/toJson. 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":
case "default", "toJson":
// toJson only serializes what its (validated) arguments or piped input
// already expose; it cannot reach anything a direct reference could not.
for _, arg := range args {
if err := v.validateValue(arg, dotIsRoot); err != nil {
return err
+286
View File
@@ -0,0 +1,286 @@
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
@@ -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
+4
View File
@@ -111,6 +111,9 @@ 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
@@ -224,6 +227,7 @@ type TraefikOidc struct {
dcrMu sync.Mutex
shutdownOnce sync.Once
sessionInvalidationCache CacheInterface
frontchannelMarkers markerQuota
refreshResultCache CacheInterface
minimalHeaders bool
stripAuthCookies bool
+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"