Compare commits

...
3 Commits
Author SHA1 Message Date
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
17 changed files with 830 additions and 62 deletions
-52
View File
@@ -1,52 +0,0 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
open-pull-requests-limit: 5
commit-message:
prefix: "chore(deps)"
include: "scope"
labels:
- "dependencies"
- "github-actions"
reviewers:
- "lukaszraczylo"
# Maintain Go module dependencies
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
open-pull-requests-limit: 10
commit-message:
prefix: "chore(deps)"
include: "scope"
labels:
- "dependencies"
- "go"
reviewers:
- "lukaszraczylo"
# Group patch updates together
groups:
patch-updates:
patterns:
- "*"
update-types:
- "patch"
minor-updates:
patterns:
- "*"
update-types:
- "minor"
# Ignore certain dependencies if needed
ignore:
# Example: ignore specific versions
# - dependency-name: "github.com/example/package"
# versions: ["1.x", "2.x"]
+7
View File
@@ -72,6 +72,13 @@ testData:
value: "{{.Claims.email}}"
- name: Authorization
value: "Bearer {{.AccessToken}}"
# Object/array claim as one JSON header; the claim must be whitelisted.
- name: X-Tenant
value: '{{get .Claims "tenant" | toJson}}'
# Extends the built-in claim whitelist for templated headers.
allowedClaims:
- tenant
# Security headers (default profile is enabled out of the box)
securityHeaders:
+28 -2
View File
@@ -441,8 +441,34 @@ headers:
Rendering the whole context (`{{.}}`, `{{$}}`), the whole claims map
(`{{.Claims}}`), or any non-listed claim is rejected — this prevents a template
from accidentally forwarding raw tokens or unlisted claims. `range`/`with` must
target a specific listed claim (e.g. `{{range .Claims.groups}}`); `get`/`default`
are the only functions allowed.
target a specific listed claim (e.g. `{{range .Claims.groups}}`); `get`, `default`
and `toJson` are the only functions allowed.
### Forwarding an object claim as JSON
`toJson` serializes a claim (object, array or scalar) to compact JSON, so a
backend can parse the whole claim itself instead of the middleware flattening
it into one header per key:
```yaml
allowedClaims:
- tenant
headers:
- name: X-Tenant
value: '{{get .Claims "tenant" | toJson}}' # {"id":"t-1","plan":"pro"}
- name: X-Groups
value: "{{toJson .Claims.groups}}" # ["admin","users"]
```
- The claim must be on the whitelist (built-in or `allowedClaims`).
`{{toJson .Claims}}`, `{{toJson .}}` and non-listed claims are rejected.
- An absent or empty claim skips the header instead of sending `""`.
- Control characters are JSON-escaped, so a claim cannot inject extra header
lines.
- A rendered value over 8192 bytes, or one with bidi-override characters, is
dropped. The oversize case is logged at error level (header name only, never
the value); keep the claim small, since proxies commonly cap headers at
8-16 KB.
> **File-provider users: escape the braces.** Traefik's file provider runs
> every dynamic configuration file through Go templating before the plugin
+9 -1
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
}
+94
View File
@@ -139,6 +139,7 @@ func main() {
runCheck("middleware-erraborthandler-aborts-under-yaegi", checkErrAbortHandlerAbortsUnderYaegi)
runCheck("fix17-setifabsent-claims-once", checkSetIfAbsentUnderYaegi)
runCheck("issue162-websocket-forwards-header-templates", checkIssue162WebSocketHeaderTemplates)
runCheck("tojson-header-template", checkToJsonHeaderTemplate)
runCheck("streaming-refresh-revoked-grant-rejected", checkStreamingRefreshRevokedGrant)
fmt.Println("OK: all yaegi regression checks passed")
}
@@ -807,6 +808,99 @@ func checkIssue162WebSocketHeaderTemplates() (string, error) {
return "", nil
}
// checkToJsonHeaderTemplate pins the toJson template helper under the
// interpreter: its (string, error) closure, json.Marshal of an object claim,
// the nil/absent-claim skip and the validator's rejection of a whole-claims dump.
func checkToJsonHeaderTemplate() (string, error) {
discovery := servers.NewOIDCServer(nil)
defer discovery.Close()
cfg := oidc.CreateConfig()
cfg.ProviderURL = discovery.URL
cfg.ClientID = "yaegi-check-tojson-client"
cfg.ClientSecret = "yaegi-check-tojson-secret"
cfg.CallbackURL = "/oauth2/callback"
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef"
cfg.AllowedClaims = []string{"tenant"}
cfg.Headers = []oidc.TemplatedHeader{
{Name: "X-Tenant", Value: `{{ get .Claims "tenant" | toJson }}`},
{Name: "X-Groups", Value: "{{ toJson .Claims.groups }}"},
{Name: "X-Missing", Value: "{{ toJson .Claims.department }}"},
}
if err := cfg.Validate(); err != nil {
return "", fmt.Errorf("toJson headers must validate: %w", err)
}
bad := oidc.CreateConfig()
*bad = *cfg
bad.Headers = []oidc.TemplatedHeader{{Name: "X-All", Value: "{{ toJson .Claims }}"}}
if err := bad.Validate(); err == nil {
return "", fmt.Errorf("toJson of the whole claims map must fail validation")
}
var seen http.Header
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.Header.Clone()
})
h, err := oidc.New(context.Background(), next, cfg, "yaegi-check-tojson")
if err != nil {
return "", fmt.Errorf("New: %w", err)
}
if closer, ok := h.(interface{ Close() error }); ok {
defer closer.Close()
}
sm, err := oidc.NewSessionManager(cfg.SessionEncryptionKey, cfg.ForceHTTPS, cfg.CookieDomain, cfg.CookiePrefix, time.Duration(cfg.SessionMaxAge)*time.Second, oidc.NewLogger("error"))
if err != nil {
return "", fmt.Errorf("NewSessionManager: %w", err)
}
defer sm.Shutdown()
setupReq := httptest.NewRequest("GET", "/ws", nil)
session, err := sm.GetSession(setupReq)
if err != nil {
return "", fmt.Errorf("GetSession: %w", err)
}
if err := session.SetAuthenticated(true); err != nil {
return "", fmt.Errorf("SetAuthenticated: %w", err)
}
session.SetUserIdentifier("[email protected]")
b64 := base64.RawURLEncoding.EncodeToString
sig := make([]byte, 32)
for i := range sig {
sig[i] = byte(i + 1)
}
session.SetIDToken(b64([]byte(`{"alg":"RS256","typ":"JWT"}`)) + "." + b64([]byte(`{"tenant":{"id":"t-1","plan":"pro"},"groups":["a","b"]}`)) + "." + b64(sig))
rec := httptest.NewRecorder()
if err := session.Save(setupReq, rec); err != nil {
return "", fmt.Errorf("session.Save: %w", err)
}
session.ReturnToPool()
req := httptest.NewRequest("GET", "/ws", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
rw := httptest.NewRecorder()
h.ServeHTTP(rw, req)
if seen == nil {
return "", fmt.Errorf("next was not reached (status %d)", rw.Code)
}
if got, want := seen.Get("X-Tenant"), `{"id":"t-1","plan":"pro"}`; got != want {
return "", fmt.Errorf("X-Tenant = %q, want %q", got, want)
}
if got, want := seen.Get("X-Groups"), `["a","b"]`; got != want {
return "", fmt.Errorf("X-Groups = %q, want %q", got, want)
}
if got := seen.Get("X-Missing"); got != "" {
return "", fmt.Errorf("X-Missing = %q, want header skipped", got)
}
return "", nil
}
// checkStreamingRefreshRevokedGrant drives streamingRefresh under the
// interpreter through the real RefreshCoordinator and token exchanger: an
// expired session whose refresh the IdP answers with invalid_grant must get
+16 -1
View File
@@ -571,7 +571,7 @@ be emitted — the standard OIDC claims plus common provider claims (`email`,
`phone_number`, `email_verified`, `updated_at`; the authoritative list is
`safeClaimsFields` in `template_validation.go`). Rejected: rendering the whole
context (`{{.}}`, `{{$}}`) or the whole claims map (`{{.Claims}}`), any claim not
on the list, functions other than `get`/`default`, and `range`/`with` that do not
on the list, functions other than `get`/`default`/`toJson`, and `range`/`with` that do not
target a specific listed claim (e.g. `{{range .Claims}}` is rejected; `{{range
.Claims.groups}}` is allowed).
@@ -592,6 +592,21 @@ Listed names apply to both direct access (`{{.Claims.employee_id}}`) and
claim (e.g. `realm_access.roles`) are already accessible without listing them
individually.
**`toJson`** forwards an object or array claim as one JSON header:
```yaml
allowedClaims:
- tenant
headers:
- name: "X-Tenant"
value: "{{get .Claims \"tenant\" | toJson}}"
```
It only serializes values the template can already reach, so the whitelist
still applies; `{{toJson .Claims}}` is rejected. Control characters are
JSON-escaped, and values over 8192 bytes or with bidi-override characters are
dropped; an oversize value is logged at error level (header name only).
---
## Security Headers
+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)
}
}
}
+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())
}
}
+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 {
+39
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
+14 -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 ""
+24 -1
View File
@@ -158,6 +158,15 @@ func (w *optionsPreflightWriter) Write(b []byte) (int, error) {
return len(b), nil
}
const (
// maxInitWaiters bounds requests parked on initComplete so a boot-time
// flood with the IdP down cannot hold unbounded connections for 30s each.
maxInitWaiters = 512
// initWaitRetryAfter is the Retry-After value (seconds) sent when the
// init-wait queue is full.
initWaitRetryAfter = "5"
)
// bypassReason describes why a request is being forwarded without OIDC auth.
// It is only used for logging and to decide whether extra side-effects
// (propagating the user header from an existing session) should run.
@@ -701,6 +710,13 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// Log waiting for initialization to help diagnose hanging requests
t.logger.Debug("Waiting for OIDC provider initialization...")
if atomic.AddInt32(&t.initWaiters, 1) > maxInitWaiters {
atomic.AddInt32(&t.initWaiters, -1)
rw.Header().Set("Retry-After", initWaitRetryAfter)
t.sendErrorResponse(rw, req, "OIDC provider initialization in progress - please try again later", http.StatusServiceUnavailable)
return
}
// time.NewTimer + Stop avoids leaking a goroutine+channel for 30s on every
// request when initComplete fires quickly (would happen with time.After).
initTimer := time.NewTimer(30 * time.Second)
@@ -708,6 +724,7 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
select {
case <-t.initComplete:
atomic.AddInt32(&t.initWaiters, -1)
// Read issuerURL via atomic snapshot when available — replaces the
// metadataMu.RLock that previously fired on every non-bypass request.
// Under Yaegi each RLock acquisition costs 1-5ms of interpreter
@@ -744,10 +761,12 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
return
}
case <-req.Context().Done():
atomic.AddInt32(&t.initWaiters, -1)
t.logger.Debug("Request canceled while waiting for OIDC initialization")
t.sendErrorResponse(rw, req, "Request canceled", http.StatusRequestTimeout)
return
case <-initTimer.C:
atomic.AddInt32(&t.initWaiters, -1)
t.logger.Error("Timeout waiting for OIDC initialization")
t.sendErrorResponse(rw, req, "Timeout waiting for OIDC provider initialization - please try again later", http.StatusServiceUnavailable)
return
@@ -1371,7 +1390,11 @@ func (t *TraefikOidc) applyHeaderTemplates(req *http.Request, p *principal) {
// forwarding a tainted value. Do not log the value (it commonly
// carries the access token); log only name + reason.
if reason := headerValueReason(headerValue, headerTemplateMaxLen); reason != "" {
t.logger.Debugf("Dropping templated header %s: value failed sanitization (%s)", headerName, reason)
if reason == headerReasonTooLong {
t.logger.Errorf("Dropping templated header %s: rendered value exceeds %d bytes", headerName, headerTemplateMaxLen)
} else {
t.logger.Debugf("Dropping templated header %s: value failed sanitization (%s)", headerName, reason)
}
continue
}
req.Header.Set(headerName, headerValue)
+10 -1
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.
//
@@ -937,6 +941,9 @@ func (sm *SessionManager) EnhanceSessionSecurity(options *sessions.Options, r *h
// service). Keep it unless it is a strict parent of the serving
// 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
}
@@ -1024,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
@@ -1037,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
+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)
}
}
+4
View File
@@ -111,6 +111,9 @@ type TraefikOidc struct {
// recovery attempt. Stored atomically so the hot ServeHTTP path can
// throttle retries without acquiring metadataRetryMutex on every request.
lastMetadataRetryNano int64
// initWaiters counts requests parked on initComplete; bounded by
// maxInitWaiters so a boot-time flood cannot hold unbounded connections.
initWaiters int32
// firstRequestStarted is 0 until the very first non-health request fires
// the background-task bootstrap; then it flips to 1 via CAS. Replaces the
// firstRequestMutex + firstRequestReceived combo which previously took
@@ -224,6 +227,7 @@ type TraefikOidc struct {
dcrMu sync.Mutex
shutdownOnce sync.Once
sessionInvalidationCache CacheInterface
frontchannelMarkers markerQuota
refreshResultCache CacheInterface
minimalHeaders bool
stripAuthCookies bool