{"repro_id":"REPRO-2026-00352","version":6,"title":"Traefik digestAuth middleware gives empty secret to unknown usernames → auth bypass","repro_type":"security","status":"published","severity":"high","description":"GHSA-24qw-84q9-39wj: Traefik's digestAuth middleware gives unknown usernames an empty secret instead of rejecting them. An attacker can compute a valid digest response with password=\"\" and bypass authentication on any digest-protected route. Fixed in v2.11.55/v3.6.12. Affected: Traefik < v2.11.55, < v3.6.12. No public PoC.","root_cause":"# RCA Report — Traefik digestAuth authentication bypass (GHSA-24qw-84q9-39wj)\n\n## Summary\n\nTraefik's `digestAuth` middleware secret provider returns an **empty string** for a username that is absent from the configured htdigest user list, instead of signalling \"no such user\". The pinned `github.com/containous/go-http-auth` fork accepted that empty string as the user's `HA1` digest secret. Because every other input to the digest computation (`realm`, `nonce`, `opaque`, `uri`, `nc`, `cnonce`, `qop`, HTTP method) is either chosen by the client or handed to it in the `401` challenge, a remote **unauthenticated** attacker can forge a mathematically valid `Authorization: Digest` header for an **arbitrary unknown username** with `HA1 == \"\"` and reach any route protected by `digestAuth`. This was reproduced end-to-end against the real product (official Traefik Docker images) through the real HTTP endpoint: the forged request returned `HTTP 200` with the protected backend resource on vulnerable images and `401` on fixed images.\n\n## Impact\n\n- **Package/component affected:** Traefik `pkg/middlewares/auth/digest_auth.go` (`secretDigest()`) combined with the pinned dependency `github.com/abbot/go-http-auth => github.com/containous/go-http-auth v0.4.1-0.20200324110947-a37a7636d23e` (`DigestAuth.CheckAuth()`).\n- **Affected versions (empirically verified this run):** `traefik:v2.11.54` (bypassed), `traefik:v3.6.11` (bypassed), and — contrary to the ticket's fixed-version claim — `traefik:v3.6.12` (**still bypassed**). Every release shipping the middleware without the fix is affected.\n- **Risk level / consequences:** Complete authentication bypass (high/critical). Any route protected only by `digestAuth` — including the Traefik dashboard/API when protected this way — is fully accessible without credentials. The forged username is also written to the access log's auth-user field, corrupting the audit trail.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Authentication bypass / authz bypass on any digestAuth-protected route via a crafted digest response for an unknown username (`authz_bypass`, surface `api_remote`).\n- **Reproduced impact from this run:** Full parity for the claimed impact. A single remote HTTP request with a forged `Authorization: Digest` header for an unknown username (`attacker-unknown-user`) obtained `HTTP 200` and the protected backend body `PROTECTED-BACKEND-RESOURCE-OK` through the real Traefik HTTP endpoint on the vulnerable images; fixed images rejected the identical request with `401`. Valid credentials still work (`200`) and a wrong password is still rejected (`401`) on every image, proving the middleware was exercised normally and only the unknown-user path is broken.\n- **Parity:** `full`\n- **Not demonstrated:** Nothing further is claimed by the ticket. (No code execution is involved; the impact is authorization bypass, which was demonstrated in full.)\n\n## Root Cause\n\n1. **Traefik `pkg/middlewares/auth/digest_auth.go`** builds the authenticator with `goauth.NewDigestAuthenticator(realm, d.secretDigest)`. Its secret provider is:\n   ```go\n   func (d *digestAuth) secretDigest(user, realm string) string {\n       if secret, ok := d.users[user+\":\"+realm]; ok {\n           return secret\n       }\n       return \"\" // <-- unknown user gets an EMPTY secret instead of \"reject\"\n   }\n   ```\n   The users map is populated from the htdigest file (`user:realm:HA1` lines) by `getUsers`/`digestUserParser`.\n2. **The pinned fork** `github.com/containous/go-http-auth` (replace directive in `go.mod`, pinned at `a37a7636d23e`) implements `DigestAuth.CheckAuth()` as:\n   ```go\n   HA1 := da.Secrets(auth[\"username\"], da.Realm)\n   // no check that HA1 == \"\" means \"unknown user\"\n   HA2 := H(r.Method + \":\" + auth[\"uri\"])\n   KD := H(strings.Join([]string{HA1, auth[\"nonce\"], auth[\"nc\"], auth[\"cnonce\"], auth[\"qop\"], HA2}, \":\"))\n   if subtle.ConstantTimeCompare([]byte(KD), []byte(auth[\"response\"])) != 1 { return \"\", nil }\n   ```\n   With `HA1 == \"\"`, the attacker's expected response is `MD5(\":\" + nonce + \":\" + nc + \":\" + cnonce + \":auth:\" + MD5(\"GET:\" + uri))` — trivially computable from the `401` challenge (nonce, opaque) and the attacker's own choices. Traefik's caller then only checks `username == \"\"` to decide failure, so the forged unknown username authenticates successfully.\n3. **Fix:** the fork commit `b975dcaa8c48` adds to `CheckAuth()`:\n   ```go\n   HA1 := da.Secrets(auth[\"username\"], da.Realm)\n   if HA1 == \"\" {\n       return \"\", nil\n   }\n   ```\n   Traefik pulled the fixed fork in commit **`2116686308a2518bf1851a39eeec738f1e901195`** (\"Bump github.com/containous/go-http-auth to b975dcaa8c48\", go.mod replace directive `v0.4.1-0.20260804094822-b975dcaa8c48`).\n\n**Fixed-commit anchoring (verified via git, see `bundle/logs/git_source_verification.log`):**\n- Fix commit `21166863` is contained in tag `v2.11.55` (commit `1ac90fccb982f6de40f557493152bdb0c9f0a809`) — verified fixed at runtime.\n- Fix commit `21166863` is **absent from every `v3.6.x` tag** (checked `v3.6.0`–`v3.6.25`: all still pin fork `a37a7636d23e`). The first `v3` tag containing the fix is `v3.7.11` — verified fixed at runtime.\n- Consequently the ticket's \"Fixed in ... v3.6.12\" is **incorrect**: `traefik:v3.6.12` (tag commit `b782bd32d444af99d76e5f87970b02a9aa80ba97`, image digest `sha256:171c9c3565b29f6c133f1c1b43c5d4e5853415198e9e1078c001f8702ff66aec`) was empirically **still vulnerable** in this run.\n\n## Reproduction Steps\n\n1. Script: `bundle/repro/reproduction_steps.sh` (self-contained; creates all configs, the backend service, and the attacker client itself at runtime).\n2. What it does:\n   - Deploys the **real product** from official Docker images on a private Docker network: a `python:3-alpine` backend serving `PROTECTED-BACKEND-RESOURCE-OK`, and Traefik instances (static+dynamic file config) with a router `PathPrefix(`/protected`)` protected by the real `digestAuth` middleware (`usersFile` htdigest with one user `test` / realm `traefik` / password `secret`), plus an unprotected `/open` router for health checks.\n   - Per image, runs **two clean attempts** (fresh container each): readiness wait, healthcheck (`/open` → 200, `/protected/` no-auth → 401), then the attacker sequence through the real HTTP endpoint:\n     1. `GET /protected/` with no credentials → capture `401` challenge (`realm`, `nonce`, `opaque`);\n     2. **Attack:** forged `Authorization: Digest` for unknown username `attacker-unknown-user` with `HA1 = \"\"`, `response = MD5(\":\"+nonce+\":00000001:83cfda9a:auth:\"+MD5(\"GET:/protected/\"))`;\n     3. Sanity: known user with correct password (expect 200);\n     4. Sanity: known user with wrong password (expect 401).\n   - Image matrix: `traefik:v2.11.54` (vulnerable primary), `traefik:v2.11.55` (fixed primary control), `traefik:v3.6.11` (vulnerable v3 line), `traefik:v3.6.12` (ticket-claimed fixed — tested empirically), `traefik:v3.7.11` (first actually-fixed v3 control).\n   - Writes per-attempt request/response transcripts, a machine-readable `repro/exploit_results.json`, a supplementary git source verification log, and `repro/runtime_manifest.json`.\n3. Expected evidence of reproduction (both consecutive runs produced exactly this):\n   - `v2.11.54`, `v3.6.11`, **and `v3.6.12`**: attack step → `HTTP 200`, body `PROTECTED-BACKEND-RESOURCE-OK` (authentication **bypassed**, request forwarded to the protected backend).\n   - `v2.11.55` and `v3.7.11`: attack step → `HTTP 401` with a fresh digest challenge (rejected).\n   - Sanity on every image: correct credentials → 200, wrong password → 401.\n\n## Evidence\n\n- Per-attempt HTTP transcripts and JSON results: `bundle/repro/artifacts/http/{vuln_primary,fixed_primary,vuln_v3,claimed_fixed_v3,fixed_v3_control}_v*._attempt{1,2}.{txt,json}`\n- Aggregated verdict: `bundle/repro/exploit_results.json`\n- Runtime manifest (image digests, target identity, artifact hashes): `bundle/repro/runtime_manifest.json`\n- Git source verification (fix-commit ancestry for all five tags, fork diff): `bundle/logs/git_source_verification.log`\n- Full run log: `bundle/logs/reproduction_steps.log`\n- Key excerpt (`vuln_primary_vv2.11.54_attempt1.txt`):\n  ```\n  === step2 ATTACK forged digest, unknown username, empty HA1 secret ===\n  > Authorization: Digest username=\"attacker-unknown-user\", realm=\"traefik\", nonce=\"4jleqQagi0BjT/09\", uri=\"/protected/\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"83cfda9a\", response=\"939d544cfabce8f635f5c4bd372dc89c\", opaque=\"YF7w7S9FYLwAFyIV\"\n  < HTTP/1.1 200\n  < body: 'PROTECTED-BACKEND-RESOURCE-OK\\n'\n  ```\n  and the fixed control (`fixed_primary_vv2.11.55_attempt1.txt`): identical forged header → `< HTTP/1.1 401` + fresh `Www-Authenticate` challenge.\n- Environment: Docker (rootless daemon, Alpine), linux/amd64; images: `traefik:v2.11.54` `sha256:f10edd30…`, `traefik:v2.11.55` `sha256:4f87b6b3…`, `traefik:v3.6.11` `sha256:acfc8065…`, `traefik:v3.6.12` `sha256:171c9c35…`, `traefik:v3.7.11` `sha256:5203c3f3…`, `python:3-alpine` backend.\n\n## Recommendations / Next Steps\n\n- **Fix (already upstream):** keep `containous/go-http-auth >= b975dcaa8c48` (reject `HA1 == \"\"` in `CheckAuth`). Defence-in-depth: make Traefik's `secretDigest()` distinguish \"no such user\" from a secret (return an error / ok flag) rather than returning `\"\"`, and reject before `CheckAuth` consumes it.\n- **Upgrade guidance:** upgrade the v2 line to **v2.11.55 or later**; for the v3 line the ticket's \"v3.6.12\" is **not** fixed — upgrade to **v3.7.11 or later** (or any build whose `go.mod` pins the fork at `b975dcaa8c48`). Operators on v3.6.x should treat digestAuth-protected routes as unprotected until they upgrade.\n- **Detection:** access-log lines whose auth-user is not a configured user on a digestAuth-protected route indicate exploitation.\n- **Testing recommendation:** add a regression test that a forged digest for a username absent from the htdigest file is rejected (the fork's `digest_test.go` gained exactly such a case in `b975dcaa8c48`).\n\n## Additional Notes\n\n- **Idempotency:** the script was executed twice consecutively; both runs exited 0 with identical results (10/10 attempts as expected, `CONFIRMED: True`). It cleans up all containers/networks it creates (start and via `trap ... EXIT`).\n- **Ticket discrepancy (important for the Judge):** the ticket/GHSA text says \"Fixed in v2.11.55/v3.6.12\". The v2.11.55 part is confirmed; the v3.6.12 part is **disproved** both by git ancestry (fix commit `21166863` is in no `v3.6.x` tag) and by runtime evidence (bypass succeeds against the official `traefik:v3.6.12` image). The first fixed v3 release is v3.7.11.\n- **Limitations:** the reproduction uses Traefik's default realm `traefik` and the default MD5/qop=auth parameters; the vulnerability is independent of those choices (the secret lookup key and the digest formula adapt to whatever the server advertises). `removeHeader: true` was enabled on the middleware; the bypass works regardless. No sanitizers were used (product-mode proof, `sanitizer_used=false`).\n","ghsa_id":"GHSA-24QW-84Q9-39WJ","cve_id":"CVE-2026-85595","cwe_id":"CWE-287","source_url":"https://github.com/traefik/traefik/security/advisories/GHSA-24qw-84q9-39wj","package":{"name":"traefik/traefik","ecosystem":"github","affected_versions":"All v1/v2/v3 releases shipping digestAuth: v2 <= v2.11.54 and v3.0.0 <= v3.7.10; v1 line, v2 minors < v2.11, v3 minors < v3.7 unmaintained and unpatched"},"reproduced_at":"2026-09-11T10:13:47.020569+00:00","duration_secs":3940.863719,"tool_calls":174,"handoffs":2,"total_cost_usd":3.333439,"agent_costs":{"claim_matcher":0.026353,"judge":0.701792,"learning_policy":0.009135,"repro":1.633003,"support":0.050552,"vuln_variant":0.912604},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.026353},"judge":{"gpt-5.6-sol":0.701792},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.009135},"repro":{"accounts/fireworks/models/glm-5p3":1.633003},"support":{"accounts/fireworks/models/glm-5p3":0.050552},"vuln_variant":{"accounts/fireworks/models/glm-5p3":0.912604}},"vulnerable_version_variant_outcome":"unknown","fix_bypass_outcome":"not_found","variant_disclosure_state":"not_applicable","quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-09-11T10:13:47.680759+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":11077,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":25957,"category":"reproduction_script"},{"path":"bundle/logs/git_source_verification.log","filename":"git_source_verification.log","size":1661,"category":"log"},{"path":"bundle/repro/artifacts/http/claimed_fixed_v3_vv3.6.12_attempt1.json","filename":"claimed_fixed_v3_vv3.6.12_attempt1.json","size":517,"category":"other"},{"path":"bundle/repro/artifacts/http/claimed_fixed_v3_vv3.6.12_attempt1.txt","filename":"claimed_fixed_v3_vv3.6.12_attempt1.txt","size":1912,"category":"other"},{"path":"bundle/repro/artifacts/http/claimed_fixed_v3_vv3.6.12_attempt2.json","filename":"claimed_fixed_v3_vv3.6.12_attempt2.json","size":517,"category":"other"},{"path":"bundle/repro/artifacts/http/claimed_fixed_v3_vv3.6.12_attempt2.txt","filename":"claimed_fixed_v3_vv3.6.12_attempt2.txt","size":1912,"category":"other"},{"path":"bundle/repro/artifacts/http/fixed_primary_vv2.11.55_attempt1.json","filename":"fixed_primary_vv2.11.55_attempt1.json","size":502,"category":"other"},{"path":"bundle/repro/artifacts/http/fixed_primary_vv2.11.55_attempt1.txt","filename":"fixed_primary_vv2.11.55_attempt1.txt","size":1987,"category":"other"},{"path":"bundle/repro/artifacts/http/fixed_primary_vv2.11.55_attempt2.json","filename":"fixed_primary_vv2.11.55_attempt2.json","size":502,"category":"other"},{"path":"bundle/repro/artifacts/http/fixed_primary_vv2.11.55_attempt2.txt","filename":"fixed_primary_vv2.11.55_attempt2.txt","size":1987,"category":"other"},{"path":"bundle/repro/artifacts/http/fixed_v3_control_vv3.7.11_attempt1.json","filename":"fixed_v3_control_vv3.7.11_attempt1.json","size":504,"category":"other"},{"path":"bundle/repro/artifacts/http/fixed_v3_control_vv3.7.11_attempt1.txt","filename":"fixed_v3_control_vv3.7.11_attempt1.txt","size":1987,"category":"other"},{"path":"bundle/repro/artifacts/http/fixed_v3_control_vv3.7.11_attempt2.json","filename":"fixed_v3_control_vv3.7.11_attempt2.json","size":504,"category":"other"},{"path":"bundle/repro/artifacts/http/fixed_v3_control_vv3.7.11_attempt2.txt","filename":"fixed_v3_control_vv3.7.11_attempt2.txt","size":1987,"category":"other"},{"path":"bundle/repro/artifacts/http/vuln_primary_vv2.11.54_attempt1.json","filename":"vuln_primary_vv2.11.54_attempt1.json","size":514,"category":"other"},{"path":"bundle/repro/artifacts/http/vuln_primary_vv2.11.54_attempt1.txt","filename":"vuln_primary_vv2.11.54_attempt1.txt","size":1912,"category":"other"},{"path":"bundle/repro/artifacts/http/vuln_primary_vv2.11.54_attempt2.json","filename":"vuln_primary_vv2.11.54_attempt2.json","size":514,"category":"other"},{"path":"bundle/repro/artifacts/http/vuln_primary_vv2.11.54_attempt2.txt","filename":"vuln_primary_vv2.11.54_attempt2.txt","size":1912,"category":"other"},{"path":"bundle/repro/artifacts/http/vuln_v3_vv3.6.11_attempt1.json","filename":"vuln_v3_vv3.6.11_attempt1.json","size":508,"category":"other"},{"path":"bundle/repro/artifacts/http/vuln_v3_vv3.6.11_attempt1.txt","filename":"vuln_v3_vv3.6.11_attempt1.txt","size":1912,"category":"other"},{"path":"bundle/repro/artifacts/http/vuln_v3_vv3.6.11_attempt2.json","filename":"vuln_v3_vv3.6.11_attempt2.json","size":508,"category":"other"},{"path":"bundle/repro/artifacts/http/vuln_v3_vv3.6.11_attempt2.txt","filename":"vuln_v3_vv3.6.11_attempt2.txt","size":1912,"category":"other"},{"path":"bundle/repro/exploit_results.json","filename":"exploit_results.json","size":2691,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":6944,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1500,"category":"other"}]}