From dc4da7b4c908421d10793f28e6b90325a2814b61 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Tue, 3 Nov 2026 01:10:20 +0000 Subject: [PATCH] fix(security): drop client values for templated headers A templated header skipped its Set when the render failed, came out empty or failed sanitising. The client's own value of that header then reached the backend. A client could forge a header such as X-Tenant by using a token that lacks the claim. The middleware now removes the inbound value first. Identity headers and Authorization are left alone. --- header_templates_owned_test.go | 54 ++++++++++++++++++++++++++++++++++ middleware.go | 18 ++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 header_templates_owned_test.go diff --git a/header_templates_owned_test.go b/header_templates_owned_test.go new file mode 100644 index 0000000..4ccbf93 --- /dev/null +++ b/header_templates_owned_test.go @@ -0,0 +1,54 @@ +package traefikoidc + +import ( + "net/http/httptest" + "testing" + "text/template" +) + +func ownedHeaderOIDC(t *testing.T, names ...string) *TraefikOidc { + t.Helper() + m := map[string]*template.Template{} + for _, n := range names { + m[n] = template.Must(template.New(n).Option("missingkey=zero").Parse("{{.Claims.tenant}}")) + } + return &TraefikOidc{logger: NewLogger("error"), headerTemplates: m} +} + +// A templated header the middleware owns must never carry a client value: +// an optional claim that renders empty must leave the header absent. +func TestTemplatedHeaderDropsClientSpoof(t *testing.T) { + o := ownedHeaderOIDC(t, "X-Tenant") + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-Tenant", "admin-corp") + o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"sub": "u"}}) + if got := req.Header.Get("X-Tenant"); got != "" { + t.Fatalf("client-supplied X-Tenant reached the backend: %q", got) + } +} + +func TestTemplatedHeaderRenderedValueWins(t *testing.T) { + o := ownedHeaderOIDC(t, "X-Tenant") + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-Tenant", "evil") + o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"tenant": "acme"}}) + if got := req.Header.Get("X-Tenant"); got != "acme" { + t.Fatalf("X-Tenant = %q, want acme", got) + } +} + +// Identity headers are injected before templates run; an empty render must +// not clobber them, and Authorization stays as received. +func TestTemplatedHeaderKeepsIdentityAndAuthorization(t *testing.T) { + o := ownedHeaderOIDC(t, "X-Forwarded-User", "Authorization") + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-Forwarded-User", "alice") + req.Header.Set("Authorization", "Bearer abc") + o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"sub": "u"}}) + if got := req.Header.Get("X-Forwarded-User"); got != "alice" { + t.Fatalf("identity header clobbered: %q", got) + } + if got := req.Header.Get("Authorization"); got != "Bearer abc" { + t.Fatalf("Authorization changed: %q", got) + } +} diff --git a/middleware.go b/middleware.go index b118056..182234d 100644 --- a/middleware.go +++ b/middleware.go @@ -1341,6 +1341,23 @@ func joinBoundedClaimHeader(values []string) string { return b.String() } +// dropInboundTemplated removes a client-supplied value for a header the +// middleware owns, so a skipped render leaves it absent instead of forwarding +// the client's. Identity headers (already stripped and re-injected) and +// Authorization (the inbound bearer credential) are left alone. +func dropInboundTemplated(req *http.Request, name string) { + canon := http.CanonicalHeaderKey(name) + if canon == "Authorization" { + return + } + for _, h := range identityHeaders { + if canon == http.CanonicalHeaderKey(h) { + return + } + } + req.Header.Del(canon) +} + // applyHeaderTemplates renders the configured header templates for p onto req. func (t *TraefikOidc) applyHeaderTemplates(req *http.Request, p *principal) { if len(t.headerTemplates) == 0 { @@ -1359,6 +1376,7 @@ func (t *TraefikOidc) applyHeaderTemplates(req *http.Request, p *principal) { } for headerName, tmpl := range t.headerTemplates { + dropInboundTemplated(req, headerName) var buf bytes.Buffer if err := tmpl.Execute(&buf, templateData); err != nil { t.logger.Errorf("Failed to execute template for header %s: %v", headerName, err)