mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-11-02 07:34:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab7220828 | ||
|
|
f0af6282dc | ||
|
|
7c5a86f4e1 | ||
|
|
94a8664403 | ||
|
|
74decb21c2 | ||
|
|
7ae75d2523 | ||
|
|
937776516d | ||
|
|
c9844d0ff2 |
@@ -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"]
|
||||
@@ -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:
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
+17
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+178
-73
@@ -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
|
||||
@@ -1248,6 +1343,68 @@ func joinBoundedClaimHeader(values []string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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 +1540,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).
|
||||
|
||||
+2
-2
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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]" {
|
||||
|
||||
+42
-8
@@ -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
|
||||
@@ -3017,6 +3043,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 +3055,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.
|
||||
|
||||
+121
-39
@@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/lukaszraczylo/traefikoidc/internal/pool"
|
||||
@@ -89,9 +90,14 @@ type TokenRetrievalResult struct {
|
||||
// and error handling to ensure data integrity and prevent security vulnerabilities
|
||||
// throughout the process.
|
||||
type ChunkManager struct {
|
||||
lastCleanup time.Time
|
||||
ctx context.Context
|
||||
mutex *sync.RWMutex
|
||||
lastCleanup time.Time
|
||||
ctx context.Context
|
||||
mutex *sync.RWMutex
|
||||
// contentValid holds keys of tokens that passed every content-only check.
|
||||
// Browsers resend the same cookie, and re-running those checks on every
|
||||
// request dominated per-request cost under yaegi. Guarded by contentMu.
|
||||
contentValid map[string]tokenTimes
|
||||
contentMu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
sessionMap map[string]*SessionEntry
|
||||
logger *Logger
|
||||
@@ -127,14 +133,15 @@ func NewChunkManager(logger *Logger) *ChunkManager {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
cm := &ChunkManager{
|
||||
logger: logger,
|
||||
mutex: &sync.RWMutex{},
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
sessionMap: make(map[string]*SessionEntry),
|
||||
maxSessions: 200, // CRITICAL FIX: Reduced from 1000 to 200 per instance
|
||||
sessionTTL: 15 * time.Minute, // CRITICAL FIX: Reduced from 24h to 15 minutes
|
||||
lastCleanup: time.Now(),
|
||||
logger: logger,
|
||||
mutex: &sync.RWMutex{},
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
sessionMap: make(map[string]*SessionEntry),
|
||||
contentValid: make(map[string]tokenTimes),
|
||||
maxSessions: 200, // CRITICAL FIX: Reduced from 1000 to 200 per instance
|
||||
sessionTTL: 15 * time.Minute, // CRITICAL FIX: Reduced from 24h to 15 minutes
|
||||
lastCleanup: time.Now(),
|
||||
}
|
||||
|
||||
// Start background cleanup routine
|
||||
@@ -293,26 +300,38 @@ func (cm *ChunkManager) processSingleToken(token string, compressed bool, config
|
||||
// Returns:
|
||||
// - TokenRetrievalResult with the validated token or validation error.
|
||||
func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRetrievalResult {
|
||||
if sizeErr := cm.validateTokenSize(token, config); sizeErr != nil {
|
||||
return TokenRetrievalResult{Token: "", Error: sizeErr}
|
||||
// Content-only checks depend on the token string alone, so a token that
|
||||
// passed them once skips them. Expiry and freshness always run.
|
||||
key := contentValidKey(token, config)
|
||||
times, cached := cm.cachedTokenTimes(key)
|
||||
|
||||
if !cached {
|
||||
if sizeErr := cm.validateTokenSize(token, config); sizeErr != nil {
|
||||
return TokenRetrievalResult{Token: "", Error: sizeErr}
|
||||
}
|
||||
|
||||
cm.validateChunkingEfficiency(token, config)
|
||||
|
||||
if contentErr := cm.validateTokenContent(token, config); contentErr != nil {
|
||||
return TokenRetrievalResult{Token: "", Error: contentErr}
|
||||
}
|
||||
times = cm.tokenTimesOf(token)
|
||||
}
|
||||
|
||||
cm.validateChunkingEfficiency(token, config)
|
||||
|
||||
if contentErr := cm.validateTokenContent(token, config); contentErr != nil {
|
||||
return TokenRetrievalResult{Token: "", Error: contentErr}
|
||||
}
|
||||
|
||||
if expErr := cm.validateTokenExpiration(token, config); expErr != nil {
|
||||
if expErr := cm.validateTokenExpiration(times, config); expErr != nil {
|
||||
return TokenRetrievalResult{Token: "", Error: expErr}
|
||||
}
|
||||
|
||||
if freshnessErr := cm.validateTokenFreshness(token, config); freshnessErr != nil {
|
||||
if freshnessErr := cm.validateTokenFreshness(times, config); freshnessErr != nil {
|
||||
return TokenRetrievalResult{Token: "", Error: freshnessErr}
|
||||
}
|
||||
|
||||
// Determine if token is opaque or JWT based on format
|
||||
// JWT tokens have exactly 2 dots (3 parts: header.payload.signature)
|
||||
if cached {
|
||||
return TokenRetrievalResult{Token: token, Error: nil}
|
||||
}
|
||||
|
||||
dotCount := strings.Count(token, ".")
|
||||
isJWT := dotCount == 2
|
||||
|
||||
@@ -338,6 +357,7 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet
|
||||
}
|
||||
}
|
||||
|
||||
cm.markContentValid(key, times)
|
||||
return TokenRetrievalResult{Token: token, Error: nil}
|
||||
}
|
||||
|
||||
@@ -350,6 +370,53 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet
|
||||
//
|
||||
// Returns:
|
||||
// - TokenRetrievalResult with the reassembled token or error.
|
||||
//
|
||||
// tokenTimes holds a token's parsed exp and iat claims. They are part of the
|
||||
// token content, so a content-cache entry keeps them to avoid re-parsing the
|
||||
// payload on every request. The checks that use them still run each time.
|
||||
type tokenTimes struct {
|
||||
exp, iat *time.Time
|
||||
expErr, iatErr error
|
||||
jwt bool
|
||||
}
|
||||
|
||||
// tokenTimesOf parses exp and iat from a JWT-shaped token.
|
||||
func (cm *ChunkManager) tokenTimesOf(token string) tokenTimes {
|
||||
if !strings.Contains(token, ".") {
|
||||
return tokenTimes{}
|
||||
}
|
||||
t := tokenTimes{jwt: true}
|
||||
t.exp, t.expErr = cm.extractJWTExpiration(token)
|
||||
t.iat, t.iatErr = cm.extractJWTIssuedAt(token)
|
||||
return t
|
||||
}
|
||||
|
||||
// contentValidCacheMax bounds contentValid; the map is cleared when full.
|
||||
const contentValidCacheMax = 4096
|
||||
|
||||
// contentValidKey keys a token by type and SHA-256 so the cache does not
|
||||
// retain token values.
|
||||
func contentValidKey(token string, config TokenConfig) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return config.Type + ":" + string(sum[:])
|
||||
}
|
||||
|
||||
func (cm *ChunkManager) cachedTokenTimes(key string) (tokenTimes, bool) {
|
||||
cm.contentMu.Lock()
|
||||
t, ok := cm.contentValid[key]
|
||||
cm.contentMu.Unlock()
|
||||
return t, ok
|
||||
}
|
||||
|
||||
func (cm *ChunkManager) markContentValid(key string, t tokenTimes) {
|
||||
cm.contentMu.Lock()
|
||||
if len(cm.contentValid) >= contentValidCacheMax {
|
||||
cm.contentValid = make(map[string]tokenTimes)
|
||||
}
|
||||
cm.contentValid[key] = t
|
||||
cm.contentMu.Unlock()
|
||||
}
|
||||
|
||||
func (cm *ChunkManager) processChunkedToken(chunks map[int]*sessions.Session, config TokenConfig) TokenRetrievalResult {
|
||||
if len(chunks) > config.MaxChunks {
|
||||
err := fmt.Errorf("too many %s token chunks (%d, max: %d)", config.Type, len(chunks), config.MaxChunks)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user