# RCA Report — Traefik digestAuth authentication bypass (GHSA-24qw-84q9-39wj)

## Summary

Traefik'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.

## Impact

- **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()`).
- **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.
- **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.

## Impact Parity

- **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`).
- **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.
- **Parity:** `full`
- **Not demonstrated:** Nothing further is claimed by the ticket. (No code execution is involved; the impact is authorization bypass, which was demonstrated in full.)

## Root Cause

1. **Traefik `pkg/middlewares/auth/digest_auth.go`** builds the authenticator with `goauth.NewDigestAuthenticator(realm, d.secretDigest)`. Its secret provider is:
   ```go
   func (d *digestAuth) secretDigest(user, realm string) string {
       if secret, ok := d.users[user+":"+realm]; ok {
           return secret
       }
       return "" // <-- unknown user gets an EMPTY secret instead of "reject"
   }
   ```
   The users map is populated from the htdigest file (`user:realm:HA1` lines) by `getUsers`/`digestUserParser`.
2. **The pinned fork** `github.com/containous/go-http-auth` (replace directive in `go.mod`, pinned at `a37a7636d23e`) implements `DigestAuth.CheckAuth()` as:
   ```go
   HA1 := da.Secrets(auth["username"], da.Realm)
   // no check that HA1 == "" means "unknown user"
   HA2 := H(r.Method + ":" + auth["uri"])
   KD := H(strings.Join([]string{HA1, auth["nonce"], auth["nc"], auth["cnonce"], auth["qop"], HA2}, ":"))
   if subtle.ConstantTimeCompare([]byte(KD), []byte(auth["response"])) != 1 { return "", nil }
   ```
   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.
3. **Fix:** the fork commit `b975dcaa8c48` adds to `CheckAuth()`:
   ```go
   HA1 := da.Secrets(auth["username"], da.Realm)
   if HA1 == "" {
       return "", nil
   }
   ```
   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`).

**Fixed-commit anchoring (verified via git, see `bundle/logs/git_source_verification.log`):**
- Fix commit `21166863` is contained in tag `v2.11.55` (commit `1ac90fccb982f6de40f557493152bdb0c9f0a809`) — verified fixed at runtime.
- 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.
- 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.

## Reproduction Steps

1. Script: `bundle/repro/reproduction_steps.sh` (self-contained; creates all configs, the backend service, and the attacker client itself at runtime).
2. What it does:
   - 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.
   - 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:
     1. `GET /protected/` with no credentials → capture `401` challenge (`realm`, `nonce`, `opaque`);
     2. **Attack:** forged `Authorization: Digest` for unknown username `attacker-unknown-user` with `HA1 = ""`, `response = MD5(":"+nonce+":00000001:83cfda9a:auth:"+MD5("GET:/protected/"))`;
     3. Sanity: known user with correct password (expect 200);
     4. Sanity: known user with wrong password (expect 401).
   - 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).
   - Writes per-attempt request/response transcripts, a machine-readable `repro/exploit_results.json`, a supplementary git source verification log, and `repro/runtime_manifest.json`.
3. Expected evidence of reproduction (both consecutive runs produced exactly this):
   - `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).
   - `v2.11.55` and `v3.7.11`: attack step → `HTTP 401` with a fresh digest challenge (rejected).
   - Sanity on every image: correct credentials → 200, wrong password → 401.

## Evidence

- 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}`
- Aggregated verdict: `bundle/repro/exploit_results.json`
- Runtime manifest (image digests, target identity, artifact hashes): `bundle/repro/runtime_manifest.json`
- Git source verification (fix-commit ancestry for all five tags, fork diff): `bundle/logs/git_source_verification.log`
- Full run log: `bundle/logs/reproduction_steps.log`
- Key excerpt (`vuln_primary_vv2.11.54_attempt1.txt`):
  ```
  === step2 ATTACK forged digest, unknown username, empty HA1 secret ===
  > 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"
  < HTTP/1.1 200
  < body: 'PROTECTED-BACKEND-RESOURCE-OK\n'
  ```
  and the fixed control (`fixed_primary_vv2.11.55_attempt1.txt`): identical forged header → `< HTTP/1.1 401` + fresh `Www-Authenticate` challenge.
- 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.

## Recommendations / Next Steps

- **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.
- **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.
- **Detection:** access-log lines whose auth-user is not a configured user on a digestAuth-protected route indicate exploitation.
- **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`).

## Additional Notes

- **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`).
- **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.
- **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`).
