Compare commits

...
26 Commits
Author SHA1 Message Date
lukaszraczylo 972993980e refactor(session): remove unreachable ChunkManager eviction code
ChunkManager.sessionMap is never written in this package, so the limit
and emergency eviction paths cannot run. Remove enforceSessionLimit,
findOldestSessions, CanCreateSession, EmergencyCleanup, GetSessionCount,
GetMemoryStats and globalMaxSessions, with the tests that only covered them.
The session/chunking package keeps its own copy.
2026-11-03 01:46:11 +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
lukaszraczylo 74decb21c2 docs: note where streamingRefresh sends the WebSocket cookie
On a WebSocket upgrade, the refreshed session cookie arrives on the 101
response. Traefik keeps middleware headers on that response, and RFC 6455
section 4.1 has clients store cookies from it.

Claude-Session: https://claude.ai/code/session_01G1d1n1BrHHHw8dBUkbiBPC
2026-09-28 17:28:01 +01:00
lukaszraczylo 7ae75d2523 docs: add #162 changelog entry and rollback cookie note
Add the #162 fix to the changelog. Document that a rollback to v1.0.25 or
earlier invalidates sessions, because those releases cannot read the
encrypted cookies from v1.0.26. Tell operators which cookies to delete.

Claude-Session: https://claude.ai/code/session_01G1d1n1BrHHHw8dBUkbiBPC
2026-09-28 15:32:19 +01:00
lukaszraczylo 937776516d feat(middleware): refresh expired sessions on SSE/WebSocket bypass
The SSE and WebSocket bypass does not check the token expiry. A session
stays usable for streaming until the session cookie expires, also after
the IdP disables the user. The bypass now forwards the configured headers
(#162), so this gap also gives the proxy secret to such a session.

Add the streamingRefresh option. The default is false. When it is true and
the access token has expired, the bypass refreshes the session. It uses
the ID token expiry if the access token is opaque. If the IdP rejects the
refresh with invalid_grant, the request gets 401. If the IdP cannot be
reached, the request is forwarded as before.

The bypass waits for the full coordinated refresh. A shorter wait can
drop a rotated refresh token, and the next refresh then logs the user out.

applyBypassUserHeaders now takes the ResponseWriter, so a refresh can
write the new session cookie. Add a yaegi check for the invalid_grant path
through the real token endpoint and RefreshCoordinator.

Claude-Session: https://claude.ai/code/session_01G1d1n1BrHHHw8dBUkbiBPC
2026-09-28 15:31:18 +01:00
lukaszraczylo c9844d0ff2 fix(middleware): forward configured headers on SSE/WebSocket bypass (#162)
The SSE and WebSocket bypass forwarded only X-Forwarded-User. It did not
apply the configured headers. Backends that authenticate the proxy with a
shared-secret header returned 401 on WebSocket upgrades. Version 0.8.x sent
WebSocket upgrades through the normal flow, so this is a regression.

The bypass now renders the header templates through applyHeaderTemplates,
which forwardAuthorized also uses. Templates use the ID-token claims from
the session cookie. If the session has no ID token, they use the
access-token claims.

The bypass now also rejects a session that the IdP logged out through
back-channel or front-channel logout. Without this check, a logged-out
cookie can open streams with the proxy secret attached.

Add a yaegi check for the WebSocket case. Move tokenWithGroups to the
shared JWT test helpers, as FIX-41 requires.

Claude-Session: https://claude.ai/code/session_01G1d1n1BrHHHw8dBUkbiBPC
2026-09-28 15:24:33 +01:00
62 changed files with 3195 additions and 575 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:
+13
View File
@@ -17,6 +17,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
never reads a preflight body (`settings.go`, `middleware.go`, FIX-02).
See the option table in [CONFIGURATION.md](docs/CONFIGURATION.md).
- **`streamingRefresh` (default `false`).** SSE and WebSocket requests do
not check the token expiry, so a session stays usable for streaming until
the session cookie expires. Set the option to `true` to refresh an expired
token on these requests. If the IdP rejects the refresh with
`invalid_grant`, the request gets `401`. If the IdP cannot be reached, the
request is forwarded as before. The request waits for the full refresh, so
a rotated refresh token is never lost (`middleware.go`).
### Changed
- **Opaque bearer tokens when all three bearer flags are set.** A deployment
@@ -113,6 +121,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Fixed
- SSE and WebSocket requests now get the configured `headers`, as normal
requests do. The last release sent only `X-Forwarded-User` on these
requests, so a backend that checks a shared-secret header answered `401`
on WebSocket upgrades. The same requests now also honor back-channel and
front-channel logout (`middleware.go`, #162).
- The opaque bearer path rejects a token that `RevokeToken` added to the
blacklist, for example at logout. It also rejects an opaque token whose
`nbf` is in the future (`bearer_auth.go`).
+47 -4
View File
@@ -139,6 +139,7 @@ Full reference in [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
| `disableReplayDetection` | `false` | Disable JTI cache. Use Redis instead for multi-replica. |
| `allowPrivateIPAddresses` | `false` | Permit private-IP `providerURL` (internal Keycloak, etc.). |
| `allowUnauthenticatedPreflight` | `false` | Bypass auth for a genuine CORS preflight (`OPTIONS` + `Origin` + `Access-Control-Request-Method`); the response body is still discarded. Default requires auth for every `OPTIONS` request, preflight or not. |
| `streamingRefresh` | `false` | Refresh an expired session on SSE/WebSocket requests. Reject the request when the IdP revokes the grant. |
| `minimalHeaders` | `false` | Reduce forwarded headers (mitigates HTTP 431). |
| `stripAuthCookies` | `false` | Strip OIDC cookies from backend hop (mitigates HTTP 431). |
| `caCertPath` / `caCertPEM` | none | Trust an internal CA for the provider's TLS. |
@@ -161,6 +162,14 @@ Full reference in [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
(previously signed only) and their cryptographic lifetime tracks
`sessionMaxAge` (previously a fixed 30 days). Existing cookies become invalid
on upgrade, so users re-authenticate one time.
- **A rollback to v1.0.25 or earlier also invalidates sessions.** Those
releases cannot read the encrypted cookies that v1.0.26 and later issue.
They log `securecookie: the value is not valid` and `No state in
callback`, and the browser loops through the login. After a rollback, tell
users to delete
the `_oidc_raczylo_*` cookies (or the cookies with your `cookiePrefix`)
for the affected hosts. The cookies are `HttpOnly`, so users must delete
them in the browser site-data settings.
- **Invalid configuration now fails closed at startup** instead of being
silently accepted: a `sessionEncryptionKey` shorter than 32 bytes, a
`rateLimit` below 10, a missing `callbackURL`, or a non-HTTPS remote
@@ -304,9 +313,17 @@ WebSocket upgrade. The middleware handles this automatically:
- They are **not** unauthenticated — a valid encrypted session cookie is
required, otherwise the request is rejected. The session must already exist
(i.e. the user logged in via a normal HTTP page first).
- `X-Forwarded-User` is forwarded from the session.
- `X-Forwarded-User` and the configured `headers` are forwarded from the
session. Header templates render from the claims in the session cookie.
- Validation is cookie-only (no JWK fetch), so streaming keeps working during
brief IdP outages.
brief IdP outages. Back-channel and front-channel logout still apply: a
session that the IdP logged out is rejected.
- By default, the token expiry is not checked, so a session stays usable
for streaming until the session cookie expires. Set `streamingRefresh: true`
to refresh an expired token on the request. If the IdP rejects the refresh
(`invalid_grant`), the request gets `401`. If the IdP cannot be reached,
the request is forwarded as before. On a WebSocket upgrade, the refreshed
session cookie arrives on the `101 Switching Protocols` response.
No configuration needed — this is implicit behavior.
@@ -424,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)
}
}
+259
View File
@@ -23,6 +23,7 @@ package main
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
@@ -137,6 +138,9 @@ func main() {
runCheck("fix06-sse-flush-reaches-next", checkSSEFlushReachesNext)
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")
}
@@ -723,6 +727,261 @@ func checkSSEFlushReachesNext() (string, error) {
return "", nil
}
// checkIssue162WebSocketHeaderTemplates pins #162 under the interpreter: an
// authenticated WebSocket upgrade takes the streaming bypass, which must
// still render the configured headers (static and claim-derived) for next.
func checkIssue162WebSocketHeaderTemplates() (string, error) {
discovery := servers.NewOIDCServer(nil)
defer discovery.Close()
cfg := oidc.CreateConfig()
cfg.ProviderURL = discovery.URL
cfg.ClientID = "yaegi-check-162-client"
cfg.ClientSecret = "yaegi-check-162-secret"
cfg.CallbackURL = "/oauth2/callback"
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef"
cfg.Headers = []oidc.TemplatedHeader{
{Name: "X-Proxy-Secret", Value: "s3cret"},
{Name: "X-Forwarded-Preferred-Username", Value: "{{.Claims.preferred_username}}"},
}
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-162")
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]")
// Unsigned JWT: the bypass reads cookie-sealed claims without signature
// verification. Built at runtime because a JWT literal trips the
// pre-commit secret scanner.
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(`{"preferred_username":"alice"}`)) + "." + 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 := seen.Get("X-Proxy-Secret"); got != "s3cret" {
return "", fmt.Errorf("X-Proxy-Secret = %q, want %q", got, "s3cret")
}
if got := seen.Get("X-Forwarded-Preferred-Username"); got != "alice" {
return "", fmt.Errorf("X-Forwarded-Preferred-Username = %q, want %q", got, "alice")
}
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
// 401 on a WebSocket upgrade instead of reaching next.
func checkStreamingRefreshRevokedGrant() (string, error) {
idpCfg := servers.DefaultConfig()
idpCfg.RefreshError = &servers.OIDCError{Error: "invalid_grant"}
idp := servers.NewOIDCServer(idpCfg)
defer idp.Close()
cfg := oidc.CreateConfig()
cfg.ProviderURL = idp.URL
cfg.ClientID = "yaegi-check-refresh-client"
cfg.ClientSecret = "yaegi-check-refresh-secret"
cfg.CallbackURL = "/oauth2/callback"
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef"
cfg.StreamingRefresh = true
reached := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
})
h, err := oidc.New(context.Background(), next, cfg, "yaegi-check-refresh")
if err != nil {
return "", fmt.Errorf("New: %w", err)
}
if closer, ok := h.(interface{ Close() error }); ok {
defer closer.Close()
}
// The streaming bypass does not wait for provider metadata, so a normal
// request (which does) runs first to make the token endpoint known.
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
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]")
// Built at runtime because a JWT literal trips the pre-commit secret scanner.
b64 := base64.RawURLEncoding.EncodeToString
sig := make([]byte, 32)
for i := range sig {
sig[i] = byte(i + 1)
}
payload := fmt.Sprintf(`{"email":"[email protected]","exp":%d}`, time.Now().Add(-time.Minute).Unix())
session.SetAccessToken(b64([]byte(`{"alg":"RS256","typ":"JWT"}`)) + "." + b64([]byte(payload)) + "." + b64(sig))
session.SetRefreshToken("yaegi-check-refresh-token")
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 reached {
return "", fmt.Errorf("next reached for a revoked grant (status %d)", rw.Code)
}
if rw.Code != http.StatusUnauthorized {
return "", fmt.Errorf("status = %d, want %d", rw.Code, http.StatusUnauthorized)
}
return "", nil
}
// checkErrAbortHandlerAbortsUnderYaegi guards the re-review finding on
// ServeHTTP's ErrAbortHandler branch (middleware.go): under yaegi v0.16.1,
// `r == http.ErrAbortHandler` only ever matches a panic raised by COMPILED
+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)
}
}
}
+1
View File
@@ -76,6 +76,7 @@ func (c Config) configToMap() map[string]interface{} {
result["enablePKCE"] = c.EnablePKCE
result["allowPrivateIPAddresses"] = c.AllowPrivateIPAddresses
result["allowUnauthenticatedPreflight"] = c.AllowUnauthenticatedPreflight
result["streamingRefresh"] = c.StreamingRefresh
result["minimalHeaders"] = c.MinimalHeaders
result["stripAuthCookies"] = c.StripAuthCookies
result["cookiePath"] = c.CookiePath
+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)
}
})
}
+1 -78
View File
@@ -921,82 +921,6 @@ func TestMemoryTestOrchestrator_StopLeakDetection_CoverageBoost(t *testing.T) {
// CHUNK MANAGER TESTS
// =============================================================================
func TestChunkManager_GetSessionCount_CoverageBoost(t *testing.T) {
logger := NewLogger("debug")
cm := NewChunkManager(logger)
if cm == nil {
t.Fatal("Expected non-nil ChunkManager")
}
defer cm.Shutdown()
// Test GetSessionCount
count := cm.GetSessionCount()
if count != 0 {
t.Errorf("Expected 0 sessions, got %d", count)
}
}
func TestChunkManager_GetMemoryStats_CoverageBoost(t *testing.T) {
logger := NewLogger("debug")
cm := NewChunkManager(logger)
if cm == nil {
t.Fatal("Expected non-nil ChunkManager")
}
defer cm.Shutdown()
// Test GetMemoryStats
stats := cm.GetMemoryStats()
if stats == nil {
t.Fatal("Expected non-nil stats")
}
// Verify expected keys exist
if _, ok := stats["active_sessions"]; !ok {
t.Error("Expected active_sessions key in stats")
}
if _, ok := stats["max_sessions"]; !ok {
t.Error("Expected max_sessions key in stats")
}
if _, ok := stats["bytes_allocated"]; !ok {
t.Error("Expected bytes_allocated key in stats")
}
}
func TestChunkManager_CanCreateSession_CoverageBoost(t *testing.T) {
logger := NewLogger("debug")
cm := NewChunkManager(logger)
if cm == nil {
t.Fatal("Expected non-nil ChunkManager")
}
defer cm.Shutdown()
// Test CanCreateSession - should be true initially
canCreate, err := cm.CanCreateSession()
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if !canCreate {
t.Error("Expected CanCreateSession to return true when empty")
}
}
func TestChunkManager_EmergencyCleanup_CoverageBoost(t *testing.T) {
logger := NewLogger("debug")
cm := NewChunkManager(logger)
if cm == nil {
t.Fatal("Expected non-nil ChunkManager")
}
defer cm.Shutdown()
// Test EmergencyCleanup - should not panic on empty session map
cm.EmergencyCleanup()
// Verify no sessions exist
if cm.GetSessionCount() != 0 {
t.Error("Expected 0 sessions after cleanup")
}
}
func TestChunkManager_CleanupExpiredSessions_CoverageBoost(t *testing.T) {
logger := NewLogger("debug")
cm := NewChunkManager(logger)
@@ -1008,8 +932,7 @@ func TestChunkManager_CleanupExpiredSessions_CoverageBoost(t *testing.T) {
// Test CleanupExpiredSessions - should not panic on empty session map
cm.CleanupExpiredSessions()
// Verify no sessions exist
if cm.GetSessionCount() != 0 {
if len(cm.sessionMap) != 0 {
t.Error("Expected 0 sessions after cleanup")
}
}
+17 -1
View File
@@ -313,6 +313,7 @@ strictAudienceValidation: true
| `disableReplayDetection` | bool | `false` | Disable JTI-based replay attack detection |
| `allowPrivateIPAddresses` | bool | `false` | Allow private IPs in provider URLs |
| `allowUnauthenticatedPreflight` | bool | `false` | Bypass auth for a genuine CORS preflight (`OPTIONS` with `Origin` + `Access-Control-Request-Method`); response body is still discarded. Default requires auth for every `OPTIONS` request. |
| `streamingRefresh` | bool | `false` | Refresh an expired session on SSE/WebSocket requests. Reject the request with `401` when the IdP revokes the grant (`invalid_grant`). Forward it as before when the IdP cannot be reached. |
### Discovered Endpoint Validation
@@ -570,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).
@@ -591,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
}
+240
View File
@@ -0,0 +1,240 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"text/template"
"time"
)
// Issue #162: the SSE/WebSocket bypass forwarded only X-Forwarded-User, not
// the operator-configured `headers`. Backends that authenticate the proxy
// with a shared-secret header (Frigate proxy auth) answered 401 on /ws while
// normal requests, which go through forwardAuthorized, kept working.
func issue162Templates(t *testing.T, headers map[string]string) map[string]*template.Template {
t.Helper()
funcMap := headerTemplateFuncMap(claimsWhitelist(nil))
out := make(map[string]*template.Template, len(headers))
for name, value := range headers {
tmpl, err := template.New(name).Funcs(funcMap).Option("missingkey=zero").Parse(value)
if err != nil {
t.Fatalf("parse template %s: %v", name, err)
}
out[name] = tmpl
}
return out
}
// issue162Request builds a request carrying a saved authenticated session.
// idClaims and accessClaims are optional; nil leaves that token unset.
func issue162Request(t *testing.T, sm *SessionManager, idClaims, accessClaims map[string]interface{}) *http.Request {
t.Helper()
setupReq := httptest.NewRequest(http.MethodGet, "https://app.example.com/ws", nil)
session, err := sm.GetSession(setupReq)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if err := session.SetAuthenticated(true); err != nil {
t.Fatalf("SetAuthenticated: %v", err)
}
session.SetUserIdentifier("[email protected]")
if idClaims != nil {
session.SetIDToken(tokenWithGroups(t, idClaims))
}
if accessClaims != nil {
session.SetAccessToken(tokenWithGroups(t, accessClaims))
}
rr := httptest.NewRecorder()
if err := session.Save(setupReq, rr); err != nil {
t.Fatalf("Save: %v", err)
}
session.returnToPoolSafely()
req := httptest.NewRequest(http.MethodGet, "https://app.example.com/ws", nil)
for _, c := range rr.Result().Cookies() {
req.AddCookie(c)
}
return req
}
func issue162Middleware(t *testing.T, sm *SessionManager, templates map[string]*template.Template, seen *http.Header) *TraefikOidc {
t.Helper()
initComplete := make(chan struct{})
close(initComplete)
return &TraefikOidc{
logger: GetSingletonNoOpLogger(),
name: "test",
sessionManager: sm,
initComplete: initComplete,
extractClaimsFunc: extractClaims,
groupClaimName: "groups",
headerTemplates: templates,
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*seen = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}),
}
}
func issue162SessionManager(t *testing.T) *SessionManager {
t.Helper()
sm, err := NewSessionManager(strings.Repeat("k", 32), 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)
}
})
return sm
}
func TestIssue162_StreamingBypassForwardsHeaderTemplates(t *testing.T) {
templates := map[string]string{
"X-Proxy-Secret": "s3cret",
"X-Forwarded-Preferred-Username": "{{.Claims.preferred_username}}",
"X-Forwarded-Groups": "{{range $i, $e := .Claims.groups}}{{if $i}},{{end}}{{$e}}{{end}}",
}
idClaims := map[string]interface{}{
"preferred_username": "alice",
"groups": []string{"admins", "viewers"},
}
cases := []struct {
name string
prepare func(*http.Request)
}{
{"websocket", func(r *http.Request) {
r.Header.Set("Upgrade", "websocket")
r.Header.Set("Connection", "Upgrade")
}},
{"sse", func(r *http.Request) {
r.Header.Set("Accept", "text/event-stream")
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sm := issue162SessionManager(t)
var seen http.Header
m := issue162Middleware(t, sm, issue162Templates(t, templates), &seen)
req := issue162Request(t, sm, idClaims, nil)
tc.prepare(req)
rw := httptest.NewRecorder()
m.ServeHTTP(rw, req)
if seen == nil {
t.Fatalf("next not invoked (status=%d body=%q)", rw.Code, rw.Body.String())
}
want := map[string]string{
"X-Forwarded-User": "[email protected]",
"X-Proxy-Secret": "s3cret",
"X-Forwarded-Preferred-Username": "alice",
"X-Forwarded-Groups": "admins,viewers",
}
for h, v := range want {
if got := seen.Get(h); got != v {
t.Errorf("%s = %q, want %q", h, got, v)
}
}
})
}
}
// Opaque-ID-token providers: normal requests render templates from
// access-token claims when the session has no ID token, so the bypass must too.
func TestIssue162_StreamingBypassTemplatesFallBackToAccessTokenClaims(t *testing.T) {
sm := issue162SessionManager(t)
var seen http.Header
m := issue162Middleware(t, sm, issue162Templates(t, map[string]string{
"X-Forwarded-Preferred-Username": "{{.Claims.preferred_username}}",
}), &seen)
req := issue162Request(t, sm, nil, map[string]interface{}{"preferred_username": "bob"})
req.Header.Set("Accept", "text/event-stream")
m.ServeHTTP(httptest.NewRecorder(), req)
if seen == nil {
t.Fatal("next not invoked")
}
if got := seen.Get("X-Forwarded-Preferred-Username"); got != "bob" {
t.Fatalf("X-Forwarded-Preferred-Username = %q, want %q", got, "bob")
}
}
// An unauthenticated upgrade must still be rejected before any template
// (which can carry a shared secret) is rendered onto the request.
func TestIssue162_StreamingBypassRejectsUnauthenticatedWithoutTemplates(t *testing.T) {
sm := issue162SessionManager(t)
var seen http.Header
m := issue162Middleware(t, sm, issue162Templates(t, map[string]string{"X-Proxy-Secret": "s3cret"}), &seen)
req := httptest.NewRequest(http.MethodGet, "https://app.example.com/ws", nil)
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
rw := httptest.NewRecorder()
m.ServeHTTP(rw, req)
if seen != nil {
t.Fatal("next invoked for unauthenticated upgrade")
}
if rw.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rw.Code)
}
if req.Header.Get("X-Proxy-Secret") != "" {
t.Fatal("shared-secret template rendered onto a rejected request")
}
}
// Now that the bypass forwards proxy credentials (#162), it must honor
// IdP-initiated logout like the normal path does; otherwise a logged-out
// cookie keeps opening WebSocket/SSE with the shared secret attached.
func TestIssue162_StreamingBypassRejectsIdPLoggedOutSession(t *testing.T) {
idClaims := map[string]interface{}{
"sid": "sess-162",
"sub": "alice",
"iat": float64(time.Now().Add(-time.Minute).Unix()),
}
cases := []struct {
name string
invalidate bool
wantStatus int
}{
{"active session forwards", false, http.StatusOK},
{"logged-out session rejected", true, http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sm := issue162SessionManager(t)
cache := NewCache()
defer cache.Close()
var seen http.Header
m := issue162Middleware(t, sm, issue162Templates(t, map[string]string{"X-Proxy-Secret": "s3cret"}), &seen)
m.enableBackchannelLogout = true
m.sessionInvalidationCache = cache
if tc.invalidate {
if err := m.invalidateSession("sess-162", ""); err != nil {
t.Fatalf("invalidateSession: %v", err)
}
}
req := issue162Request(t, sm, idClaims, nil)
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
rw := httptest.NewRecorder()
m.ServeHTTP(rw, req)
if rw.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d", rw.Code, tc.wantStatus)
}
if forwarded := seen != nil; forwarded != (tc.wantStatus == http.StatusOK) {
t.Fatalf("next invoked = %v, want %v", forwarded, tc.wantStatus == http.StatusOK)
}
})
}
}
+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")
}
}
+18
View File
@@ -80,3 +80,21 @@ func makeTestJWT(t *testing.T, claims map[string]interface{}) string {
return base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl) + ".c2ln"
}
// tokenWithGroups builds a JWT whose signature segment is long enough to pass
// the session chunk manager's format validation (>=10 chars) so the access
// token round-trips through Set/GetAccessToken.
func tokenWithGroups(t *testing.T, claims map[string]interface{}) string {
t.Helper()
hdr, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"})
pl, _ := json.Marshal(claims)
// Varied (non-repeating) signature bytes so the session chunk manager's
// repeated-character heuristic does not reject it.
sigBytes := make([]byte, 32)
for i := range sigBytes {
sigBytes[i] = byte(i + 1)
}
sig := base64.RawURLEncoding.EncodeToString(sigBytes)
return base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl) + "." + sig
}
+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)
}
}
+22 -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 ""
@@ -336,6 +349,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
dcrConfig: config.DynamicClientRegistration,
allowPrivateIPAddresses: config.AllowPrivateIPAddresses,
allowUnauthenticatedPreflight: config.AllowUnauthenticatedPreflight,
streamingRefresh: config.StreamingRefresh,
allowLoopbackHosts: isLoopbackProviderURL(config.ProviderURL),
minimalHeaders: config.MinimalHeaders,
stripAuthCookies: config.StripAuthCookies,
@@ -534,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()
@@ -930,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
@@ -1069,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
}
+198 -77
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.
@@ -273,7 +282,7 @@ func stripIdentityHeaders(req *http.Request) {
}
}
func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) (bool, int) {
func (t *TraefikOidc) applyBypassUserHeaders(rw http.ResponseWriter, req *http.Request, reason string) (bool, int) {
if t.sessionManager == nil {
return false, http.StatusUnauthorized
}
@@ -296,6 +305,30 @@ func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) (
return false, http.StatusUnauthorized
}
// Opt-in: refresh an expired session so an IdP-revoked grant stops opening
// streams. Only a definitive rejection (refreshToken clears the session on
// invalid_grant) denies; IdP outages keep forwarding as before. The full
// coordinator wait is kept so a rotated refresh token is never dropped.
if t.streamingRefresh && bypassTokenExpired(session) {
if !t.refreshToken(rw, req, session) && !session.GetAuthenticated() {
t.logger.Infof("User %s refresh rejected by IdP (bypass)", userIdentifier)
return false, http.StatusUnauthorized
}
}
// Mirror the normal path's IdP-initiated logout check: this path forwards
// configured headers (often a shared proxy secret), so a logged-out cookie
// must not keep opening streams until the absolute session timeout.
if t.enableBackchannelLogout || t.enableFrontchannelLogout {
if idToken := session.GetIDToken(); idToken != "" {
sid, sub, _ := t.extractSessionInfo(idToken)
if t.isSessionInvalidated(sid, sub, t.sessionCreatedAtForInvalidation(idToken, session)) {
t.logger.Infof("User %s session invalidated via IdP-initiated logout (bypass)", userIdentifier)
return false, http.StatusUnauthorized
}
}
}
// Enforce the user allowlist, mirroring the normal path's authorization
// check (isAllowedUser). Without this, an authenticated user not in
// allowedUsers (or whose domain is not in allowedUserDomains) could
@@ -314,19 +347,18 @@ func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) (
// fallback, opaque-ID-token providers (groups only in the access token)
// were granted on normal requests but 403'd on the streaming bypass
// (R118).
var claims map[string]interface{}
var claimsErr error
if len(t.allowedRolesAndGroups) > 0 || len(t.headerTemplates) > 0 {
claims, claimsErr = t.bypassSessionClaims(session)
}
if len(t.allowedRolesAndGroups) > 0 {
var groupClaims map[string]interface{}
var claimsErr error
if idToken := session.GetIDToken(); idToken != "" {
groupClaims, claimsErr = session.GetIDTokenClaims(t.extractClaimsFunc)
} else if accessToken := session.GetAccessToken(); accessToken != "" {
groupClaims, claimsErr = t.extractClaimsFunc(accessToken)
}
if claimsErr != nil || groupClaims == nil {
if claimsErr != nil || claims == nil {
t.logger.Debugf("%s bypass: cannot read claims for role check (err=%v): %s", reason, claimsErr, userIdentifier)
return false, http.StatusForbidden
}
groups, roles, extErr := t.extractGroupsAndRolesFromClaims(groupClaims)
groups, roles, extErr := t.extractGroupsAndRolesFromClaims(claims)
if extErr != nil {
t.logger.Debugf("%s bypass: role extraction failed for %s: %v", reason, userIdentifier, extErr)
return false, http.StatusForbidden
@@ -351,8 +383,8 @@ func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) (
// only sets X-Forwarded-User / X-Auth-Request-User (never groups/roles),
// forged group/role headers would otherwise survive to the backend,
// which commonly trusts them for downstream authorization. Must precede
// the sanitize gate so even the drop-on-unsafe early return has already
// cleared forged values (R100).
// the sanitize gate so forged values are cleared even when the
// identifier is dropped as unsafe (R100).
stripIdentityHeaders(req)
// Sanitize the claim-derived identifier before it is injected as a
@@ -361,20 +393,73 @@ func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) (
// or delimiter characters that would otherwise inject or confuse
// downstream header parsing. On failure, drop the header but still
// honor the bypass (the identity headers are decoration here).
safeIdentifier, ok := sanitizeHeaderClaimValue(userIdentifier, t.headerClaimMaxLen())
if !ok {
if safeIdentifier, ok := sanitizeHeaderClaimValue(userIdentifier, t.headerClaimMaxLen()); ok {
req.Header.Set("X-Forwarded-User", safeIdentifier)
if !t.minimalHeaders {
req.Header.Set("X-Auth-Request-User", safeIdentifier)
}
t.logger.Debugf("%s bypass: forwarded user %s from session", reason, safeIdentifier)
} else {
t.logger.Debugf("%s bypass: dropping unsafe user-identifier header: %s", reason, headerClaimValueReason(userIdentifier, t.headerClaimMaxLen()))
return true, 0
}
req.Header.Set("X-Forwarded-User", safeIdentifier)
if !t.minimalHeaders {
req.Header.Set("X-Auth-Request-User", safeIdentifier)
// Backends that authenticate the proxy via configured headers (shared
// secret, user/groups) need them on the upgrade too (#162). Claims are
// cookie-sealed but not re-verified here; see the doc comment above.
if len(t.headerTemplates) > 0 {
if claimsErr != nil {
t.logger.Debugf("%s bypass: rendering header templates without claims: %v", reason, claimsErr)
}
t.applyHeaderTemplates(req, &principal{
Source: sourceSession,
Identifier: userIdentifier,
AccessToken: session.GetAccessToken(),
IDToken: session.GetIDToken(),
RefreshToken: session.GetRefreshToken(),
Claims: claims,
})
}
t.logger.Debugf("%s bypass: forwarded user %s from session", reason, safeIdentifier)
return true, 0
}
// bypassTokenExpired reports whether the session's access token, or its ID
// token when the access token carries no readable exp (opaque), has expired.
// exp is read unverified: the cookie is sealed and a verified refresh follows.
func bypassTokenExpired(session *SessionData) bool {
for _, token := range []string{session.GetAccessToken(), session.GetIDToken()} {
if token == "" {
continue
}
jwt, err := parseJWT(token)
if err != nil {
continue
}
if exp, ok := jwt.Claims["exp"].(float64); ok {
return !time.Now().Before(time.Unix(int64(exp), 0))
}
}
return false
}
// bypassSessionClaims returns the session's ID-token claims, falling back to
// access-token claims when there is no ID token, matching the source the
// normal authorization path uses (R118).
func (t *TraefikOidc) bypassSessionClaims(session *SessionData) (map[string]interface{}, error) {
if t.extractClaimsFunc == nil {
return nil, nil
}
// Explicit assignments, not tail-call returns: yaegi can zero
// multi-value tail-call results (see issue151_regression_test.go).
var claims map[string]interface{}
var err error
if session.GetIDToken() != "" {
claims, err = session.GetIDTokenClaims(t.extractClaimsFunc)
} else if accessToken := session.GetAccessToken(); accessToken != "" {
claims, err = t.extractClaimsFunc(accessToken)
}
return claims, err
}
// ServeHTTP implements the main middleware logic for processing HTTP requests.
// It handles the complete OIDC authentication flow including:
// - Excluded URL bypass
@@ -594,7 +679,7 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// Otherwise an unauthenticated client could hit the backend
// just by setting Accept: text/event-stream or sending a
// WebSocket upgrade.
if ok, status := t.applyBypassUserHeaders(req, reason); !ok {
if ok, status := t.applyBypassUserHeaders(rw, req, reason); !ok {
msg := "Authentication required"
if status == http.StatusForbidden {
msg = "Access denied"
@@ -625,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)
@@ -632,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
@@ -668,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
@@ -959,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 {
@@ -1052,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
@@ -1248,6 +1341,86 @@ 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 {
return
}
// p.Claims may be nil (e.g. session without an ID token). Templates
// referencing .Claims.* will simply produce empty values — matches
// the prior behavior. Bearer-source principals always carry access-
// token claims (post-verifyToken).
templateData := map[string]interface{}{
"AccessToken": p.AccessToken,
"IDToken": p.IDToken,
"IdToken": p.IDToken, // documented spelling (README/CONFIGURATION); alias so {{.IdToken}} renders (issue #149 review)
"RefreshToken": p.RefreshToken,
"Claims": p.Claims,
}
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)
continue
}
headerValue := buf.String()
// Go's text/template renders a missing key in an interface-valued
// map as the literal "<no value>" even with missingkey=zero
// (zero only applies to typed keys). Replace that sentinel so an
// optional claim the provider did not emit (e.g. email) does not
// leak "<no value>" into the downstream header.
if strings.Contains(headerValue, noValueSentinel) {
headerValue = stripNoValueSentinels(headerValue)
}
// Skip an empty render: Setting "" would clobber an identity
// header (X-Forwarded-User / X-Auth-Request-*) the caller already
// injected with the authenticated value, silently
// dropping the user identity at the backend.
if headerValue == "" {
t.logger.Debugf("Skipping templated header %s: rendered value is empty", headerName)
continue
}
// Sanitize the rendered output: template inputs are claim-derived
// and attacker-influenceable, so reject control chars (header
// injection), bidi-override runes, the , ; = delimiters, and an
// over-long value. Fail closed by dropping the header rather than
// forwarding a tainted value. Do not log the value (it commonly
// carries the access token); log only name + reason.
if reason := headerValueReason(headerValue, headerTemplateMaxLen); reason != "" {
if reason == headerReasonTooLong {
t.logger.Errorf("Dropping templated header %s: rendered value exceeds %d bytes", headerName, headerTemplateMaxLen)
} else {
t.logger.Debugf("Dropping templated header %s: value failed sanitization (%s)", headerName, reason)
}
continue
}
req.Header.Set(headerName, headerValue)
// Do not log the value: templated headers commonly carry the access
// token (e.g. "Authorization: Bearer {{.AccessToken}}"), and logging
// it — even at debug — leaks credentials into logs.
t.logger.Debugf("Set templated header %s (%d bytes)", headerName, len(headerValue))
}
}
func (t *TraefikOidc) forwardAuthorized(rw http.ResponseWriter, req *http.Request, p *principal) {
// This middleware fully owns the identity headers it injects upstream.
// Unconditionally remove any inbound values with the same names first:
@@ -1383,59 +1556,7 @@ func (t *TraefikOidc) forwardAuthorized(rw http.ResponseWriter, req *http.Reques
req.Header.Del("Authorization")
}
if len(t.headerTemplates) > 0 {
// p.Claims may be nil (e.g. session without an ID token). Templates
// referencing .Claims.* will simply produce empty values — matches
// the prior behavior. Bearer-source principals always carry access-
// token claims (post-verifyToken).
templateData := map[string]interface{}{
"AccessToken": p.AccessToken,
"IDToken": p.IDToken,
"IdToken": p.IDToken, // documented spelling (README/CONFIGURATION); alias so {{.IdToken}} renders (issue #149 review)
"RefreshToken": p.RefreshToken,
"Claims": p.Claims,
}
for headerName, tmpl := range t.headerTemplates {
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)
continue
}
headerValue := buf.String()
// Go's text/template renders a missing key in an interface-valued
// map as the literal "<no value>" even with missingkey=zero
// (zero only applies to typed keys). Replace that sentinel so an
// optional claim the provider did not emit (e.g. email) does not
// leak "<no value>" into the downstream header.
if strings.Contains(headerValue, noValueSentinel) {
headerValue = stripNoValueSentinels(headerValue)
}
// Skip an empty render: Setting "" would clobber an identity
// header (X-Forwarded-User / X-Auth-Request-*) already injected
// earlier in this function with the authenticated value, silently
// dropping the user identity at the backend.
if headerValue == "" {
t.logger.Debugf("Skipping templated header %s: rendered value is empty", headerName)
continue
}
// Sanitize the rendered output: template inputs are claim-derived
// and attacker-influenceable, so reject control chars (header
// injection), bidi-override runes, the , ; = delimiters, and an
// over-long value. Fail closed by dropping the header rather than
// 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)
continue
}
req.Header.Set(headerName, headerValue)
// Do not log the value: templated headers commonly carry the access
// token (e.g. "Authorization: Bearer {{.AccessToken}}"), and logging
// it — even at debug — leaks credentials into logs.
t.logger.Debugf("Set templated header %s (%d bytes)", headerName, len(headerValue))
}
}
t.applyHeaderTemplates(req, p)
// Strip OIDC session cookies before forwarding to the backend to prevent
// HTTP 431 "Request Header Fields Too Large" errors (GitHub issue #122).
+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")
}
}
+2 -2
View File
@@ -53,7 +53,7 @@ func TestApplyBypassUserHeaders_ClearsForgedIdentityHeaders(t *testing.T) {
logger: NewLogger("debug"),
}
if ok, _ := oidc.applyBypassUserHeaders(req, "test"); !ok {
if ok, _ := oidc.applyBypassUserHeaders(httptest.NewRecorder(), req, "test"); !ok {
t.Fatalf("applyBypassUserHeaders returned false; bypass should be honored")
}
@@ -110,7 +110,7 @@ func TestApplyBypassUserHeaders_UnsafeIdentifierClearsForgedValue(t *testing.T)
logger: NewLogger("debug"),
}
if ok, _ := oidc.applyBypassUserHeaders(req, "test"); !ok {
if ok, _ := oidc.applyBypassUserHeaders(httptest.NewRecorder(), req, "test"); !ok {
t.Fatalf("applyBypassUserHeaders returned false; bypass should be honored")
}
if v := req.Header.Get("X-Forwarded-User"); v != "" {
+1 -21
View File
@@ -2,8 +2,6 @@ package traefikoidc
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
@@ -64,24 +62,6 @@ func TestIsRetryableError_HTTPErrorStatus(t *testing.T) {
// --- R118 fix C: bypass role gate must fall back to access-token claims ---
// tokenWithGroups builds a JWT whose signature segment is long enough to pass
// the session chunk manager's format validation (>=10 chars) so the access
// token round-trips through Set/GetAccessToken.
func tokenWithGroups(t *testing.T, claims map[string]interface{}) string {
t.Helper()
hdr, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"})
pl, _ := json.Marshal(claims)
// Varied (non-repeating) signature bytes so the session chunk manager's
// repeated-character heuristic does not reject it.
sigBytes := make([]byte, 32)
for i := range sigBytes {
sigBytes[i] = byte(i + 1)
}
sig := base64.RawURLEncoding.EncodeToString(sigBytes)
return base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl) + "." + sig
}
// TestApplyBypassUserHeaders_AccessTokenGroupFallback regresses R118: the
// SSE/WebSocket bypass role gate read only the session's ID token, while the
// main authorization path falls back to access-token claims when no ID token
@@ -120,7 +100,7 @@ func TestApplyBypassUserHeaders_AccessTokenGroupFallback(t *testing.T) {
allowedRolesAndGroups: map[string]struct{}{"team-a": {}},
}
ok, status := oidc.applyBypassUserHeaders(req, "test")
ok, status := oidc.applyBypassUserHeaders(httptest.NewRecorder(), req, "test")
if !ok {
t.Fatalf("bypass role gate denied access-token group; got status %d, want granted", status)
}
+2 -2
View File
@@ -42,7 +42,7 @@ func TestApplyBypassUserHeaders_SanitizesIdentifier(t *testing.T) {
logger: NewLogger("debug"),
}
if ok, _ := oidc.applyBypassUserHeaders(req, "test"); !ok {
if ok, _ := oidc.applyBypassUserHeaders(httptest.NewRecorder(), req, "test"); !ok {
t.Fatalf("applyBypassUserHeaders returned false; bypass should be honored")
}
if v := req.Header.Get("X-Forwarded-User"); v != "" {
@@ -83,7 +83,7 @@ func TestApplyBypassUserHeaders_KeepsCleanIdentifier(t *testing.T) {
logger: NewLogger("debug"),
}
if ok, _ := oidc.applyBypassUserHeaders(req, "test"); !ok {
if ok, _ := oidc.applyBypassUserHeaders(httptest.NewRecorder(), req, "test"); !ok {
t.Fatalf("applyBypassUserHeaders returned false; bypass should be honored")
}
if v := req.Header.Get("X-Forwarded-User"); v != "[email protected]" {
+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 -265
View File
@@ -10,6 +10,7 @@ import (
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/gorilla/sessions"
"github.com/lukaszraczylo/traefikoidc/internal/pool"
@@ -31,7 +32,6 @@ type TokenConfig struct {
// Global session tracking to prevent memory leaks across all instances
var (
globalSessionCount int64 = 0
globalMaxSessions int64 = 5000 // CRITICAL FIX: Global limit of 5000 total sessions
)
// ResetGlobalSessionCounters resets global session tracking for testing
@@ -89,9 +89,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 +132,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 +299,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 +356,7 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet
}
}
cm.markContentValid(key, times)
return TokenRetrievalResult{Token: token, Error: nil}
}
@@ -350,6 +369,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 +444,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 +571,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 +786,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 +991,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 +1007,7 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi
currentRepeated = 1
}
lastChar = char
i += size
}
threshold := 20
@@ -941,8 +1018,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 +1045,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 +1144,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
}
@@ -1160,226 +1242,4 @@ func (cm *ChunkManager) CleanupExpiredSessions(force ...bool) {
cm.logger.Debugf("Cleaned up %d expired sessions, freed %d bytes",
len(expiredKeys), totalBytesFreed)
}
// Enforce max sessions limit
if len(cm.sessionMap) > cm.maxSessions {
cm.enforceSessionLimit()
}
}
// enforceSessionLimit removes oldest sessions when limit is exceeded
// Uses partial sort (O(n) for finding k smallest) instead of full sort (O(n log n))
func (cm *ChunkManager) enforceSessionLimit() {
currentLocal := len(cm.sessionMap)
currentGlobal := atomic.LoadInt64(&globalSessionCount)
// CRITICAL FIX: Aggressive eviction when approaching limits
shouldEvict := false
targetCapacity := cm.maxSessions
// Check global limit first (more critical)
if currentGlobal >= globalMaxSessions {
shouldEvict = true
targetCapacity = cm.maxSessions / 4 // Aggressive reduction to 25%
} else if currentGlobal >= globalMaxSessions*8/10 { // 80% of global
shouldEvict = true
targetCapacity = cm.maxSessions / 2 // Reduce to 50%
} else if currentLocal >= cm.maxSessions {
shouldEvict = true
targetCapacity = cm.maxSessions * 3 / 4 // Reduce to 75%
}
if !shouldEvict {
return
}
// Calculate how many sessions to remove
excessCount := currentLocal - targetCapacity
if excessCount <= 0 {
return
}
// Use partial selection instead of full sort for better performance
// For finding k oldest sessions, we only need O(n) operations
keysToRemove := cm.findOldestSessions(excessCount)
totalBytesFreed := int64(0)
removedCount := int64(0)
for _, key := range keysToRemove {
if entry, exists := cm.sessionMap[key]; exists {
totalBytesFreed += entry.SizeEstimate
atomic.AddInt64(&cm.bytesAllocated, -entry.SizeEstimate)
removedCount++
}
delete(cm.sessionMap, key)
}
// Update global count
if removedCount > 0 {
atomic.AddInt64(&globalSessionCount, -removedCount)
}
cm.logger.Infof("Enforced session limit: removed %d excess sessions, freed %d bytes",
len(keysToRemove), totalBytesFreed)
}
// findOldestSessions returns keys of the k oldest sessions efficiently
// Uses a simple approach: find the kth oldest timestamp, then collect all older entries
func (cm *ChunkManager) findOldestSessions(k int) []string {
if k <= 0 || len(cm.sessionMap) == 0 {
return nil
}
if k >= len(cm.sessionMap) {
// Remove all sessions
keys := make([]string, 0, len(cm.sessionMap))
for key := range cm.sessionMap {
keys = append(keys, key)
}
return keys
}
// Collect all timestamps with keys
type sessionAge struct {
lastUsed time.Time
key string
}
sessions := make([]sessionAge, 0, len(cm.sessionMap))
for key, entry := range cm.sessionMap {
sessions = append(sessions, sessionAge{key: key, lastUsed: entry.LastUsed})
}
// Partial sort: get the k smallest elements using selection
// This is O(n*k) which is better than O(n log n) when k << n
for i := 0; i < k; i++ {
minIdx := i
for j := i + 1; j < len(sessions); j++ {
if sessions[j].lastUsed.Before(sessions[minIdx].lastUsed) {
minIdx = j
}
}
if minIdx != i {
sessions[i], sessions[minIdx] = sessions[minIdx], sessions[i]
}
}
// Return the k oldest
result := make([]string, k)
for i := 0; i < k; i++ {
result[i] = sessions[i].key
}
return result
}
// CanCreateSession checks if a new session can be created within limits
func (cm *ChunkManager) CanCreateSession() (bool, error) {
cm.mutex.RLock()
currentCount := len(cm.sessionMap)
cm.mutex.RUnlock()
// Hard limit check - never exceed maxSessions
if currentCount >= cm.maxSessions {
cm.logger.Error("Cannot create session: at maximum limit (%d)", cm.maxSessions)
return false, fmt.Errorf("session storage at maximum capacity (%d sessions)", cm.maxSessions)
}
// Emergency cleanup at 90% capacity
emergencyThreshold := int(float64(cm.maxSessions) * 0.9)
if currentCount >= emergencyThreshold {
cm.logger.Info("Session storage at %d%% capacity, triggering emergency cleanup",
(currentCount*100)/cm.maxSessions)
cm.EmergencyCleanup()
// Recheck after cleanup
cm.mutex.RLock()
newCount := len(cm.sessionMap)
cm.mutex.RUnlock()
if newCount >= cm.maxSessions {
return false, fmt.Errorf("session storage full even after emergency cleanup (%d sessions)", newCount)
}
}
return true, nil
}
// EmergencyCleanup performs aggressive session cleanup when approaching limits
func (cm *ChunkManager) EmergencyCleanup() {
cm.mutex.Lock()
defer cm.mutex.Unlock()
now := time.Now()
removed := 0
// Remove any expired sessions first
expiredKeys := make([]string, 0)
for key, entry := range cm.sessionMap {
if now.After(entry.ExpiresAt) || now.Sub(entry.LastUsed) > cm.sessionTTL {
expiredKeys = append(expiredKeys, key)
}
}
for _, key := range expiredKeys {
if entry, exists := cm.sessionMap[key]; exists {
atomic.AddInt64(&cm.bytesAllocated, -entry.SizeEstimate)
}
delete(cm.sessionMap, key)
removed++
}
// If still over 80% capacity, remove oldest sessions more aggressively
targetCapacity := int(float64(cm.maxSessions) * 0.8)
if len(cm.sessionMap) > targetCapacity {
excessCount := len(cm.sessionMap) - targetCapacity
// Use efficient partial sort to find oldest sessions
keysToRemove := cm.findOldestSessions(excessCount)
for _, key := range keysToRemove {
if entry, exists := cm.sessionMap[key]; exists {
atomic.AddInt64(&cm.bytesAllocated, -entry.SizeEstimate)
}
delete(cm.sessionMap, key)
removed++
}
}
cm.lastCleanup = now
cm.logger.Infof("Emergency cleanup completed: removed %d sessions, %d remaining",
removed, len(cm.sessionMap))
// Log memory stats after emergency cleanup (read only, no forced GC)
cm.logger.Infof("Sessions after emergency cleanup: %d, Tracked bytes: %d",
len(cm.sessionMap), atomic.LoadInt64(&cm.bytesAllocated))
}
// GetSessionCount returns the current number of active sessions (for monitoring)
func (cm *ChunkManager) GetSessionCount() int {
cm.mutex.RLock()
defer cm.mutex.RUnlock()
return len(cm.sessionMap)
}
// GetMemoryStats returns memory usage statistics for monitoring
func (cm *ChunkManager) GetMemoryStats() map[string]interface{} {
cm.mutex.RLock()
sessionCount := len(cm.sessionMap)
cm.mutex.RUnlock()
stats := make(map[string]interface{})
stats["active_sessions"] = sessionCount
stats["max_sessions"] = cm.maxSessions
stats["bytes_allocated"] = atomic.LoadInt64(&cm.bytesAllocated)
stats["peak_sessions"] = atomic.LoadInt64(&cm.peakSessions)
stats["cleanup_count"] = atomic.LoadInt64(&cm.cleanupCount)
stats["session_ttl_hours"] = cm.sessionTTL.Hours()
// Update peak sessions
if int64(sessionCount) > atomic.LoadInt64(&cm.peakSessions) {
atomic.StoreInt64(&cm.peakSessions, int64(sessionCount))
}
return stats
}
+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)
}
}
}
+51 -2
View File
@@ -115,8 +115,12 @@ type Config struct {
// or not, like any other method. Enable only if your backend itself
// answers CORS preflights and must see them unauthenticated.
AllowUnauthenticatedPreflight bool `json:"allowUnauthenticatedPreflight,omitempty"`
MinimalHeaders bool `json:"minimalHeaders,omitempty"`
StripAuthCookies bool `json:"stripAuthCookies,omitempty"`
// StreamingRefresh refreshes an expired session on the SSE/WebSocket
// bypass and rejects the request when the IdP revokes the grant.
// Default false: the bypass trusts the session cookie until it expires.
StreamingRefresh bool `json:"streamingRefresh,omitempty"`
MinimalHeaders bool `json:"minimalHeaders,omitempty"`
StripAuthCookies bool `json:"stripAuthCookies,omitempty"`
// CookiePath restricts session cookies to a specific path prefix instead of "/".
// When traefikoidc protects some but not all paths on a domain, set this to the
// middleware's path prefix (e.g. "/app-protegido") so the browser does not send
@@ -655,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
@@ -950,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
}
@@ -1223,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 {
@@ -1249,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")
}
@@ -1282,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)
}
})
}
+189
View File
@@ -0,0 +1,189 @@
package traefikoidc
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"text/template"
"time"
)
// streamingRefreshSession stores a session whose tokens expire at exp and
// returns a WebSocket upgrade request carrying its cookies. An empty
// refreshToken leaves the refresh token unset; opaqueAccess stores a
// non-JWT access token so only the ID token carries an exp.
func streamingRefreshSession(t *testing.T, sm *SessionManager, exp time.Time, refreshToken string, opaqueAccess bool) *http.Request {
t.Helper()
setupReq := httptest.NewRequest(http.MethodGet, "https://app.example.com/ws", nil)
session, err := sm.GetSession(setupReq)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if err := session.SetAuthenticated(true); err != nil {
t.Fatalf("SetAuthenticated: %v", err)
}
session.SetUserIdentifier("[email protected]")
claims := map[string]interface{}{
"email": "[email protected]",
"preferred_username": "alice-old",
"exp": float64(exp.Unix()),
}
session.SetIDToken(tokenWithGroups(t, claims))
if opaqueAccess {
session.SetAccessToken("opaque-access-token-without-dots")
} else {
session.SetAccessToken(tokenWithGroups(t, claims))
}
if refreshToken != "" {
session.SetRefreshToken(refreshToken)
}
rr := httptest.NewRecorder()
if err := session.Save(setupReq, rr); err != nil {
t.Fatalf("Save: %v", err)
}
session.returnToPoolSafely()
req := httptest.NewRequest(http.MethodGet, "https://app.example.com/ws", nil)
for _, c := range rr.Result().Cookies() {
req.AddCookie(c)
}
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
return req
}
func TestStreamingBypassRefresh(t *testing.T) {
past := time.Now().Add(-time.Minute)
future := time.Now().Add(time.Hour)
errInvalidGrant := errors.New(`token endpoint returned 400: {"error":"invalid_grant"}`)
errTransient := errors.New("dial tcp 10.0.0.1:443: connect: connection refused")
cases := []struct {
name string
enabled bool
exp time.Time
refreshToken string
opaqueAccess bool
refreshErr error
wantStatus int
wantRefresh int
wantUsername string
}{
{name: "flag off keeps expired session forwarding", enabled: false, exp: past, refreshToken: "rt-old", wantStatus: http.StatusOK, wantRefresh: 0, wantUsername: "alice-old"},
{name: "unexpired token skips refresh", enabled: true, exp: future, refreshToken: "rt-old", wantStatus: http.StatusOK, wantRefresh: 0, wantUsername: "alice-old"},
{name: "expired token refreshes and forwards fresh claims", enabled: true, exp: past, refreshToken: "rt-old", wantStatus: http.StatusOK, wantRefresh: 1, wantUsername: "alice-new"},
{name: "opaque access token uses ID token expiry", enabled: true, exp: past, refreshToken: "rt-old", opaqueAccess: true, wantStatus: http.StatusOK, wantRefresh: 1, wantUsername: "alice-new"},
{name: "revoked grant is rejected", enabled: true, exp: past, refreshToken: "rt-old", refreshErr: errInvalidGrant, wantStatus: http.StatusUnauthorized, wantRefresh: 1},
{name: "transient IdP failure still forwards", enabled: true, exp: past, refreshToken: "rt-old", refreshErr: errTransient, wantStatus: http.StatusOK, wantRefresh: 1, wantUsername: "alice-old"},
{name: "no refresh token still forwards", enabled: true, exp: past, wantStatus: http.StatusOK, wantRefresh: 0, wantUsername: "alice-old"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sm, err := NewSessionManager(strings.Repeat("k", 32), 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)
}
})
newClaims := map[string]interface{}{
"email": "[email protected]",
"preferred_username": "alice-new",
"exp": float64(future.Unix()),
}
exchanger := &EnhancedMockTokenExchanger{
RefreshErr: tc.refreshErr,
RefreshResponse: &TokenResponse{
AccessToken: tokenWithGroups(t, newClaims),
IDToken: tokenWithGroups(t, newClaims),
RefreshToken: "rt-new",
},
}
tmpl := template.Must(template.New("X-Forwarded-Preferred-Username").
Funcs(headerTemplateFuncMap(claimsWhitelist(nil))).
Option("missingkey=zero").
Parse("{{.Claims.preferred_username}}"))
initComplete := make(chan struct{})
close(initComplete)
var seen http.Header
m := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
name: "test",
sessionManager: sm,
initComplete: initComplete,
extractClaimsFunc: extractClaims,
userIdentifierClaim: "email",
tokenExchanger: exchanger,
tokenVerifier: &EnhancedMockTokenVerifier{},
streamingRefresh: tc.enabled,
headerTemplates: map[string]*template.Template{"X-Forwarded-Preferred-Username": tmpl},
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}),
}
req := streamingRefreshSession(t, sm, tc.exp, tc.refreshToken, tc.opaqueAccess)
rw := httptest.NewRecorder()
m.ServeHTTP(rw, req)
if rw.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d", rw.Code, tc.wantStatus)
}
if got := len(exchanger.RefreshCalls); got != tc.wantRefresh {
t.Fatalf("refresh calls = %d, want %d", got, tc.wantRefresh)
}
if tc.wantStatus != http.StatusOK {
if seen != nil {
t.Fatal("next invoked for a rejected request")
}
return
}
if seen == nil {
t.Fatal("next not invoked")
}
if got := seen.Get("X-Forwarded-Preferred-Username"); got != tc.wantUsername {
t.Fatalf("X-Forwarded-Preferred-Username = %q, want %q", got, tc.wantUsername)
}
if tc.wantRefresh == 1 && tc.refreshErr == nil && len(rw.Result().Cookies()) == 0 {
t.Fatal("refreshed session was not written back as a cookie")
}
})
}
}
func TestStreamingRefreshConfigWiring(t *testing.T) {
if CreateConfig().StreamingRefresh {
t.Fatal("streamingRefresh must default to false")
}
for _, enabled := range []bool{false, true} {
config := CreateConfig()
config.ProviderURL = "https://provider.example.com"
config.ClientID = "test-client-id"
config.ClientSecret = "test-secret"
config.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
config.CallbackURL = "/callback"
config.StreamingRefresh = enabled
if got, ok := config.configToMap()["streamingRefresh"].(bool); !ok || got != enabled {
t.Fatalf("configToMap streamingRefresh = %v (present=%v), want %v", got, ok, enabled)
}
oidc, err := NewWithContext(context.Background(), config, http.NotFoundHandler(), "test")
if err != nil {
t.Fatalf("NewWithContext: %v", err)
}
if oidc.streamingRefresh != enabled {
t.Errorf("streamingRefresh = %v, want %v", oidc.streamingRefresh, enabled)
}
if err := oidc.Close(); err != nil {
t.Errorf("Close: %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
+6
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
@@ -235,6 +239,8 @@ type TraefikOidc struct {
// (default false): gates whether shouldBypassAuth's genuine-preflight
// check (middleware.go) bypasses OIDC auth at all.
allowUnauthenticatedPreflight bool
// streamingRefresh mirrors Config.StreamingRefresh (default false).
streamingRefresh bool
// allowLoopbackHosts permits loopback/localhost hosts in outbound URL
// validation (validateHost). Derived at construction time from a loopback
// providerURL (local development), never operator-set directly. Mirrors
+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)