GHSA-f52w-8j3h-j724
Traefik: Rootless HTTP/1 request-target routes as "/" but is forwarded verbatim, bypassing path-scoped routing, middleware guards and access logging
Quick fix
GHSA-f52w-8j3h-j724 — github.com/traefik/traefik/v3: upgrade to the fixed version with the command below.
go get github.com/traefik/traefik/v3@v3.7.13Details
## 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 normalization. | | Envoy | CVE-2023-27491 | medium | Envoy **forwards invalid HTTP/2 and HTTP/3 downstream headers** to the upstream instead of rejecting them. | Reject malformed downstream input at the edge. | | Istio | CVE-2021-39156 | high | Fragments in the path lead to authorization policy bypass. | Normalize before policy evaluation. | | HAProxy | CVE-2023-25725 | CRITICAL | HTTP/1 headers inadvertently lost in some conditions, allowing a bypass of access control. | Restore consistent parsing. |
_(truncated ; full analysis in the linked internal report)_
## Recommended fix
**Assign for fix, and treat as CVE-worthy.**
1. Clear the opaque form when rebuilding the outbound URL, in both proxies. In `pkg/proxy/httputil/proxy.go`, next to the Path/RawPath assignments: ```go pr.Out.URL.Opaque = "" ``` and in `pkg/proxy/fast/proxy.go`, on the `u2 := *req.URL` copy before `outReq.SetRequestURI(u2.RequestURI())`. This alone closes the forwarding half. 2. Reject non-origin-form request-targets at the entry point, which is the stronger fix and the one matching the competitor remediation shape (Envoy CVE-2023-27491: reject malformed downstream framing at the edge rather than relaying it). For non-`CONNECT` requests, require `req.URL.Opaque == ""` and an `EscapedPath()` beginning with `/`, or normalize the true `absolute-form` case by promoting the authority into `req.Host`. This makes the router, the path sanitizers, the middlewares, the access log and the backend agree on a single interpretation of the target, which step 1 alone does not achieve: without it, `sanitizePath` still rewrites `RequestURI` to the attacker's authority-bearing form and the access log still records `/`. 3. Add a regression test asserting that a rootless request-target either is rejected at the entry point or reaches the backend as an origin-form target derived from the routed path. The probe used above is a direct starting point. 4. Consider reporting the `ReverseProxy` omission upstream to Go as well, since `NewSingleHostReverseProxy` has the same gap, but do not make the Traefik fix wait on it. 5. If filed as an advisory, use cluster slug `opaque-request-target-forwarding` and note that the fix must land on the fast proxy in the same PR.
## Provenance
Found by an external automated code scan (`CLAUDE-SECURITY-20260824-122205`) of `pkg/middlewares`, `pkg/proxy`, `pkg/server`, `pkg/muxer` and `pkg/tls` on branch `v3.7` at commit `d5072ce7b8765c9574246072e05dd81d84950da7`, then triaged with the `advisory-check` process : mechanism-level duplicate check against the existing advisory corpus, CVE-policy gate, security-documentation grounding, comparable-project precedent, and a mandatory reproduction attempt.
Triage outcome : **Likely Valid**, confidence High, reproduced (yes). Expected publication likelihood at triage time : High.
Scanner finding ids : F4, F20. Internal report : `findings/scan-20260824/verdicts/J04.md, J18.md` in the security-advisor repository.
</details> ---
Are you affected?
Enter the version of the package you're using.
Affected packages
3.0.0Fixed in: 3.7.13go get github.com/traefik/traefik/v3@v3.7.130Fixed in: 2.11.57go get github.com/traefik/traefik/v2@v2.11.57References
- https://github.com/traefik/traefik/security/advisories/GHSA-f52w-8j3h-j724[WEB]
- https://nvd.nist.gov/vuln/detail/CVE-2026-88009[ADVISORY]
- https://github.com/traefik/traefik/pull/13796[WEB]
- https://github.com/traefik/traefik/commit/58d1e9ca204526823211e30fd4634101c59d58e9[WEB]
- https://github.com/traefik/traefik[PACKAGE]
- https://github.com/traefik/traefik/releases/tag/v2.11.57[WEB]
- https://github.com/traefik/traefik/releases/tag/v3.7.13[WEB]