fix(middleware): forward configured headers on SSE/WebSocket bypass (#162)

The SSE and WebSocket bypass forwarded only X-Forwarded-User. It did not
apply the configured headers. Backends that authenticate the proxy with a
shared-secret header returned 401 on WebSocket upgrades. Version 0.8.x sent
WebSocket upgrades through the normal flow, so this is a regression.

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

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

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

Claude-Session: https://claude.ai/code/session_01G1d1n1BrHHHw8dBUkbiBPC
This commit is contained in:
2026-09-28 15:24:33 +01:00
parent 45e99551f9
commit c9844d0ff2
6 changed files with 468 additions and 93 deletions
+4 -2
View File
@@ -304,9 +304,11 @@ WebSocket upgrade. The middleware handles this automatically:
- They are **not** unauthenticated — a valid encrypted session cookie is - They are **not** unauthenticated — a valid encrypted session cookie is
required, otherwise the request is rejected. The session must already exist required, otherwise the request is rejected. The session must already exist
(i.e. the user logged in via a normal HTTP page first). (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 - 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.
No configuration needed — this is implicit behavior. No configuration needed — this is implicit behavior.
+83
View File
@@ -23,6 +23,7 @@ package main
import ( import (
"context" "context"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -137,6 +138,7 @@ func main() {
runCheck("fix06-sse-flush-reaches-next", checkSSEFlushReachesNext) runCheck("fix06-sse-flush-reaches-next", checkSSEFlushReachesNext)
runCheck("middleware-erraborthandler-aborts-under-yaegi", checkErrAbortHandlerAbortsUnderYaegi) runCheck("middleware-erraborthandler-aborts-under-yaegi", checkErrAbortHandlerAbortsUnderYaegi)
runCheck("fix17-setifabsent-claims-once", checkSetIfAbsentUnderYaegi) runCheck("fix17-setifabsent-claims-once", checkSetIfAbsentUnderYaegi)
runCheck("issue162-websocket-forwards-header-templates", checkIssue162WebSocketHeaderTemplates)
fmt.Println("OK: all yaegi regression checks passed") fmt.Println("OK: all yaegi regression checks passed")
} }
@@ -723,6 +725,87 @@ func checkSSEFlushReachesNext() (string, error) {
return "", nil 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
}
// checkErrAbortHandlerAbortsUnderYaegi guards the re-review finding on // checkErrAbortHandlerAbortsUnderYaegi guards the re-review finding on
// ServeHTTP's ErrAbortHandler branch (middleware.go): under yaegi v0.16.1, // ServeHTTP's ErrAbortHandler branch (middleware.go): under yaegi v0.16.1,
// `r == http.ErrAbortHandler` only ever matches a panic raised by COMPILED // `r == http.ErrAbortHandler` only ever matches a panic raised by COMPILED
+240
View File
@@ -0,0 +1,240 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"text/template"
"time"
)
// Issue #162: the SSE/WebSocket bypass forwarded only X-Forwarded-User, not
// the operator-configured `headers`. Backends that authenticate the proxy
// with a shared-secret header (Frigate proxy auth) answered 401 on /ws while
// normal requests, which go through forwardAuthorized, kept working.
func issue162Templates(t *testing.T, headers map[string]string) map[string]*template.Template {
t.Helper()
funcMap := headerTemplateFuncMap(claimsWhitelist(nil))
out := make(map[string]*template.Template, len(headers))
for name, value := range headers {
tmpl, err := template.New(name).Funcs(funcMap).Option("missingkey=zero").Parse(value)
if err != nil {
t.Fatalf("parse template %s: %v", name, err)
}
out[name] = tmpl
}
return out
}
// issue162Request builds a request carrying a saved authenticated session.
// idClaims and accessClaims are optional; nil leaves that token unset.
func issue162Request(t *testing.T, sm *SessionManager, idClaims, accessClaims map[string]interface{}) *http.Request {
t.Helper()
setupReq := httptest.NewRequest(http.MethodGet, "https://app.example.com/ws", nil)
session, err := sm.GetSession(setupReq)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if err := session.SetAuthenticated(true); err != nil {
t.Fatalf("SetAuthenticated: %v", err)
}
session.SetUserIdentifier("[email protected]")
if idClaims != nil {
session.SetIDToken(tokenWithGroups(t, idClaims))
}
if accessClaims != nil {
session.SetAccessToken(tokenWithGroups(t, accessClaims))
}
rr := httptest.NewRecorder()
if err := session.Save(setupReq, rr); err != nil {
t.Fatalf("Save: %v", err)
}
session.returnToPoolSafely()
req := httptest.NewRequest(http.MethodGet, "https://app.example.com/ws", nil)
for _, c := range rr.Result().Cookies() {
req.AddCookie(c)
}
return req
}
func issue162Middleware(t *testing.T, sm *SessionManager, templates map[string]*template.Template, seen *http.Header) *TraefikOidc {
t.Helper()
initComplete := make(chan struct{})
close(initComplete)
return &TraefikOidc{
logger: GetSingletonNoOpLogger(),
name: "test",
sessionManager: sm,
initComplete: initComplete,
extractClaimsFunc: extractClaims,
groupClaimName: "groups",
headerTemplates: templates,
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*seen = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}),
}
}
func issue162SessionManager(t *testing.T) *SessionManager {
t.Helper()
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
t.Cleanup(func() {
if err := sm.Shutdown(); err != nil {
t.Errorf("Shutdown: %v", err)
}
})
return sm
}
func TestIssue162_StreamingBypassForwardsHeaderTemplates(t *testing.T) {
templates := map[string]string{
"X-Proxy-Secret": "s3cret",
"X-Forwarded-Preferred-Username": "{{.Claims.preferred_username}}",
"X-Forwarded-Groups": "{{range $i, $e := .Claims.groups}}{{if $i}},{{end}}{{$e}}{{end}}",
}
idClaims := map[string]interface{}{
"preferred_username": "alice",
"groups": []string{"admins", "viewers"},
}
cases := []struct {
name string
prepare func(*http.Request)
}{
{"websocket", func(r *http.Request) {
r.Header.Set("Upgrade", "websocket")
r.Header.Set("Connection", "Upgrade")
}},
{"sse", func(r *http.Request) {
r.Header.Set("Accept", "text/event-stream")
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sm := issue162SessionManager(t)
var seen http.Header
m := issue162Middleware(t, sm, issue162Templates(t, templates), &seen)
req := issue162Request(t, sm, idClaims, nil)
tc.prepare(req)
rw := httptest.NewRecorder()
m.ServeHTTP(rw, req)
if seen == nil {
t.Fatalf("next not invoked (status=%d body=%q)", rw.Code, rw.Body.String())
}
want := map[string]string{
"X-Forwarded-User": "[email protected]",
"X-Proxy-Secret": "s3cret",
"X-Forwarded-Preferred-Username": "alice",
"X-Forwarded-Groups": "admins,viewers",
}
for h, v := range want {
if got := seen.Get(h); got != v {
t.Errorf("%s = %q, want %q", h, got, v)
}
}
})
}
}
// Opaque-ID-token providers: normal requests render templates from
// access-token claims when the session has no ID token, so the bypass must too.
func TestIssue162_StreamingBypassTemplatesFallBackToAccessTokenClaims(t *testing.T) {
sm := issue162SessionManager(t)
var seen http.Header
m := issue162Middleware(t, sm, issue162Templates(t, map[string]string{
"X-Forwarded-Preferred-Username": "{{.Claims.preferred_username}}",
}), &seen)
req := issue162Request(t, sm, nil, map[string]interface{}{"preferred_username": "bob"})
req.Header.Set("Accept", "text/event-stream")
m.ServeHTTP(httptest.NewRecorder(), req)
if seen == nil {
t.Fatal("next not invoked")
}
if got := seen.Get("X-Forwarded-Preferred-Username"); got != "bob" {
t.Fatalf("X-Forwarded-Preferred-Username = %q, want %q", got, "bob")
}
}
// An unauthenticated upgrade must still be rejected before any template
// (which can carry a shared secret) is rendered onto the request.
func TestIssue162_StreamingBypassRejectsUnauthenticatedWithoutTemplates(t *testing.T) {
sm := issue162SessionManager(t)
var seen http.Header
m := issue162Middleware(t, sm, issue162Templates(t, map[string]string{"X-Proxy-Secret": "s3cret"}), &seen)
req := httptest.NewRequest(http.MethodGet, "https://app.example.com/ws", nil)
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
rw := httptest.NewRecorder()
m.ServeHTTP(rw, req)
if seen != nil {
t.Fatal("next invoked for unauthenticated upgrade")
}
if rw.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rw.Code)
}
if req.Header.Get("X-Proxy-Secret") != "" {
t.Fatal("shared-secret template rendered onto a rejected request")
}
}
// Now that the bypass forwards proxy credentials (#162), it must honor
// IdP-initiated logout like the normal path does; otherwise a logged-out
// cookie keeps opening WebSocket/SSE with the shared secret attached.
func TestIssue162_StreamingBypassRejectsIdPLoggedOutSession(t *testing.T) {
idClaims := map[string]interface{}{
"sid": "sess-162",
"sub": "alice",
"iat": float64(time.Now().Add(-time.Minute).Unix()),
}
cases := []struct {
name string
invalidate bool
wantStatus int
}{
{"active session forwards", false, http.StatusOK},
{"logged-out session rejected", true, http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sm := issue162SessionManager(t)
cache := NewCache()
defer cache.Close()
var seen http.Header
m := issue162Middleware(t, sm, issue162Templates(t, map[string]string{"X-Proxy-Secret": "s3cret"}), &seen)
m.enableBackchannelLogout = true
m.sessionInvalidationCache = cache
if tc.invalidate {
if err := m.invalidateSession("sess-162", ""); err != nil {
t.Fatalf("invalidateSession: %v", err)
}
}
req := issue162Request(t, sm, idClaims, nil)
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
rw := httptest.NewRecorder()
m.ServeHTTP(rw, req)
if rw.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d", rw.Code, tc.wantStatus)
}
if forwarded := seen != nil; forwarded != (tc.wantStatus == http.StatusOK) {
t.Fatalf("next invoked = %v, want %v", forwarded, tc.wantStatus == http.StatusOK)
}
})
}
}
+18
View File
@@ -80,3 +80,21 @@ func makeTestJWT(t *testing.T, claims map[string]interface{}) string {
return base64.RawURLEncoding.EncodeToString(hdr) + "." + return base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl) + ".c2ln" 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
}
+123 -71
View File
@@ -296,6 +296,19 @@ func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) (
return false, http.StatusUnauthorized 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 // Enforce the user allowlist, mirroring the normal path's authorization
// check (isAllowedUser). Without this, an authenticated user not in // check (isAllowedUser). Without this, an authenticated user not in
// allowedUsers (or whose domain is not in allowedUserDomains) could // allowedUsers (or whose domain is not in allowedUserDomains) could
@@ -314,19 +327,18 @@ func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) (
// fallback, opaque-ID-token providers (groups only in the access token) // fallback, opaque-ID-token providers (groups only in the access token)
// were granted on normal requests but 403'd on the streaming bypass // were granted on normal requests but 403'd on the streaming bypass
// (R118). // (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 { if len(t.allowedRolesAndGroups) > 0 {
var groupClaims map[string]interface{} if claimsErr != nil || claims == nil {
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 {
t.logger.Debugf("%s bypass: cannot read claims for role check (err=%v): %s", reason, claimsErr, userIdentifier) t.logger.Debugf("%s bypass: cannot read claims for role check (err=%v): %s", reason, claimsErr, userIdentifier)
return false, http.StatusForbidden return false, http.StatusForbidden
} }
groups, roles, extErr := t.extractGroupsAndRolesFromClaims(groupClaims) groups, roles, extErr := t.extractGroupsAndRolesFromClaims(claims)
if extErr != nil { if extErr != nil {
t.logger.Debugf("%s bypass: role extraction failed for %s: %v", reason, userIdentifier, extErr) t.logger.Debugf("%s bypass: role extraction failed for %s: %v", reason, userIdentifier, extErr)
return false, http.StatusForbidden return false, http.StatusForbidden
@@ -351,8 +363,8 @@ func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) (
// only sets X-Forwarded-User / X-Auth-Request-User (never groups/roles), // only sets X-Forwarded-User / X-Auth-Request-User (never groups/roles),
// forged group/role headers would otherwise survive to the backend, // forged group/role headers would otherwise survive to the backend,
// which commonly trusts them for downstream authorization. Must precede // which commonly trusts them for downstream authorization. Must precede
// the sanitize gate so even the drop-on-unsafe early return has already // the sanitize gate so forged values are cleared even when the
// cleared forged values (R100). // identifier is dropped as unsafe (R100).
stripIdentityHeaders(req) stripIdentityHeaders(req)
// Sanitize the claim-derived identifier before it is injected as a // Sanitize the claim-derived identifier before it is injected as a
@@ -361,20 +373,54 @@ func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) (
// or delimiter characters that would otherwise inject or confuse // or delimiter characters that would otherwise inject or confuse
// downstream header parsing. On failure, drop the header but still // downstream header parsing. On failure, drop the header but still
// honor the bypass (the identity headers are decoration here). // honor the bypass (the identity headers are decoration here).
safeIdentifier, ok := sanitizeHeaderClaimValue(userIdentifier, t.headerClaimMaxLen()) if safeIdentifier, ok := sanitizeHeaderClaimValue(userIdentifier, t.headerClaimMaxLen()); ok {
if !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())) 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) // Backends that authenticate the proxy via configured headers (shared
if !t.minimalHeaders { // secret, user/groups) need them on the upgrade too (#162). Claims are
req.Header.Set("X-Auth-Request-User", safeIdentifier) // 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 return true, 0
} }
// 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. // ServeHTTP implements the main middleware logic for processing HTTP requests.
// It handles the complete OIDC authentication flow including: // It handles the complete OIDC authentication flow including:
// - Excluded URL bypass // - Excluded URL bypass
@@ -1248,6 +1294,64 @@ func joinBoundedClaimHeader(values []string) string {
return b.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 != "" {
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) { func (t *TraefikOidc) forwardAuthorized(rw http.ResponseWriter, req *http.Request, p *principal) {
// This middleware fully owns the identity headers it injects upstream. // This middleware fully owns the identity headers it injects upstream.
// Unconditionally remove any inbound values with the same names first: // Unconditionally remove any inbound values with the same names first:
@@ -1383,59 +1487,7 @@ func (t *TraefikOidc) forwardAuthorized(rw http.ResponseWriter, req *http.Reques
req.Header.Del("Authorization") req.Header.Del("Authorization")
} }
if len(t.headerTemplates) > 0 { t.applyHeaderTemplates(req, p)
// 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))
}
}
// Strip OIDC session cookies before forwarding to the backend to prevent // Strip OIDC session cookies before forwarding to the backend to prevent
// HTTP 431 "Request Header Fields Too Large" errors (GitHub issue #122). // HTTP 431 "Request Header Fields Too Large" errors (GitHub issue #122).
-20
View File
@@ -2,8 +2,6 @@ package traefikoidc
import ( import (
"context" "context"
"encoding/base64"
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "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 --- // --- 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 // TestApplyBypassUserHeaders_AccessTokenGroupFallback regresses R118: the
// SSE/WebSocket bypass role gate read only the session's ID token, while 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 // main authorization path falls back to access-token claims when no ID token