GitHub Advisoryadvisory · Go
GHSA-8fcf-v89g-xpg6 Traefik: BasicAuth singleflight coalescing reintroduces an unauthenticated username-enumeration timing oracle
MEDIUM
Official page ↗
- Aliases
- CVE-2026-88010
- Ecosystem
- Go
- Packages
- Traefik
- Severity
- Moderate
- CVSS vector
- CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
- Weaknesses
- CWE-208
- Reviewed
- 2026-09-22
Affected packages
• Go Traefik: >= 3.6.11, < 3.7.13 → fixed in 3.7.13
Description
Summary
Traefik's BasicAuth middleware coalesces concurrent credential checks through a `singleflight.Group` to avoid hashing the same password many times at once. Since v3.6.11 the deduplication key was built from the submitted password plus the stored secret, so it depended on server state: a non-existent username collapsed onto one shared key while each configured username produced its own. Under attacker-controlled concurrency, a probe request arriving inside a leader request's in-flight window is served the leader's fast coalesced result when the username does not exist, but computes its own hash (slow) when the username exists — reintroducing, only in the concurrent case, the unauthenticated username-enumeration timing oracle that GHSA-g3hg-j4jv-cwfr had hardened for sequential probing. The fix derives the singleflight key from the submitted credentials (username and password) only, so it no longer depends on whether the account exists or on any stored secret. Traefik v2 is not affected: the v2.11 BasicAuth middleware does not use singleflight coalescing, and Digest authentication is not affected. The impact is limited to username enumeration; no credential disclosure or authentication bypass is possible.
The vulnerability originates on the v3.6 line, which has reached end of life; users on v3.6 or earlier v3.x must upgrade to v3.7.13 to receive the fix.
Patches
• https://github.com/traefik/traefik/releases/tag/v3.7.13
For more information
If you have any questions or comments about this advisory, please open an issue.
Original Description
Summary
Confirmed. `checkPassword` derives the `singleflight` key from the *stored secret*, so the key encodes whether the submitted username exists:
• username absent, `secret == ""`, key `= len(P) + ":" + P`
• username present, key `= len(P) + ":" + P + secret_T`
Every non-existent username therefore lands on one shared key, while every configured username gets its own. `singleflight.Group.Do` makes a follower on an equal key block on the leader's in-flight computation and return the leader's result. So an attacker who sends a leader request with a junk username and password `P`, then sends the probe for target `T` with the same `P` late inside the leader's window, reads user existence directly off the probe's latency: coalesced (fast) means `T` does not exist, own hash (slow) means `T` does exist.
This is the exact information leak that the `notFoundSecret` dummy hash at line 127 exists to remove. Sequential probing is fully equalised (measured ratio 1.00x, so the fix for `cwfr` / `8j2h` does work); the leak reappears only under attacker-controlled concurrency. Confirmed on the current `v3.7` head, which already carries the `8mrf` singleflight fix, and on `master` (no later fix exists).
Affected code
• `pkg/middlewares/auth/basic_auth.go:125` (`checkPassword`)
Code analysis
`pkg/middlewares/auth/basic_auth.go:119-131` matches the finding verbatim, including the cited line 125:
```go
func (b *basicAuth) checkPassword(user, password string) bool { // :119
secret := b.auth.Secrets(user, b.auth.Realm) // "" when the user is absent
key := strconv.Itoa(len(password)) + ":" + password + secret // :124
match, _, _ := b.singleflightGroup.Do(key, func() (any, error) { // :125
if secret == "" {
_ = b.checkSecret(password, b.notFoundSecret) // :127 dummy hash, equal cost
return false, nil
}
return b.checkSecret(password, secret), nil
})
return match.(bool)
}
```
Two independent properties combine:
1. The dummy hash equalises the cost of one lookup. `notFoundSecret` is a configured user's real hash (`slices.Collect(maps.Values(users))[0]`), so absent and present users each perform exactly one hash of the same algorithm and cost. That is why the sequential control below is flat.
2. The key partitions on existence, so coalescing is not equalised. The dummy hash was placed *inside* the closure (commit `122175ac2`, PR #12803), which is what pulls absent users into `Do` at all. Before that refactor, `secret == ""` returned `false` before `Do` was ever called.
Git archaeology of the whole sequence in this one function:
| Commit | Date | Effect |
|---|---|---|
| `6f469ee1e` | 2024-10-10 | Introduces `singleflight` to dedupe concurrent hashes (`Only calculate basic auth hashes once for concurrent requests`). Absent users returned `false` before `Do`. |
| `122175ac2` (PR #12803) | 2026-03-17 | Fix for `cwfr`. Moves the empty-secret branch inside the closure, which makes the key existence-dependent for the first time. |
| `8c4fc8957` | 2026-04-13 | Fix for `8j2h`: `notFoundSecret` was resolving to `""`, so the dummy hash was a no-op. |
| `b5ace8eb5` (PR #13572) | 2026-07-28 | Fix for `8mrf`: adds the `len(password) + ":"` prefix so distinct (password, secret) pairs cannot alias. Keeps the secret in the key and keeps the empty-secret branch inside the closure. |
J15 is the residue of that last fix. It does not depend on the key collision `8mrf` closed: the oracle works precisely *because* the keys differ. The prior analysis of `8mrf` recommended keeping "the empty-secret (unconfigured) path out of any key that a configured user can share"; the shipped patch only delimited the key, so the existence dependency survived.
Affected range: `>= v3.6.11` (the `122175ac2` refactor) through current `v3.7` head and `master`. Not the v2 line, and not earlier v3 releases, which returned early for absent users. Digest auth does not use `singleflight` and is unaffected.
Impact scope: username enumeration only. No credential disclosure, no authentication bypass, no result sharing across identities.
Reproduction
Go tests written directly in `package auth`, driving the real `NewBasic` handler over a real HTTP server (`httptest`), with real `bcrypt` / `apr1` hashes and no instrumentation of the vulnerable logic. Files: `pkg/middlewares/auth/zz_scanpoc_J15{,b,c}_test.go`, deleted after the run.
Commands
```
cd /Users/emile/go/src/github.com/traefik/traefik
go test -count=1 -run TestZZScanPoCJ15 -v ./pkg/middlewares/auth/...
go test -count=1 -run TestZZScanPoCJ15Costs -v ./pkg/middlewares/auth/...
go test -count=1 -run TestZZScanPoCJ15H2 -v ./pkg/middlewares/auth/...
```
Probe shape, exactly the claimed scenario: calibrate `D` with one request, launch a leader with a junk username and password `P`, sleep `0.9 * D`, then send the probe with password `P` and either a configured (`alice`) or an absent (`bob`) username, and time the probe. `classify=OK` means `present > 2 * absent`, i.e. the oracle answered correctly.
Observed, main PoC (test 1)
```
[bcrypt-cost12] SEQUENTIAL control: absent=249.568275ms present=249.10215ms ratio=1.00x
[bcrypt-cost12] CONCURRENT D=245.521625ms frac=0.90 round 0: absent=21.750833ms present=242.081792ms ratio=11.1x classify=OK
[bcrypt-cost12] CONCURRENT D=245.521625ms frac=0.90 round 1: absent=20.197667ms present=245.234958ms ratio=12.1x classify=OK
[bcrypt-cost12] CONCURRENT D=245.521625ms frac=0.90 round 2: absent=25.675958ms present=243.981375ms ratio=9.5x classify=OK
[bcrypt-cost10] SEQUENTIAL control: absent=61.4374ms present=61.049608ms ratio=0.99x
[bcrypt-cost10] CONCURRENT D=60.54675ms frac=0.90 round 0: absent=5.572875ms present=60.8345ms ratio=10.9x classify=OK
[bcrypt-cost10] CONCURRENT D=60.54675ms frac=0.90 round 1: absent=4.042292ms present=61.988792ms ratio=15.3x classify=OK
[bcrypt-cost10] CONCURRENT D=60.54675ms frac=0.90 round 2: absent=4.53825ms present=61.557791ms ratio=13.6x classify=OK
[apr1-short-pw] SEQUENTIAL control: absent=360.7µs present=373.116µs ratio=1.03x
[apr1-short-pw] CONCURRENT D=370.792µs frac=0.90 round 0: absent=378.167µs present=409.458µs ratio=1.1x classify=FAIL
[apr1-short-pw] CONCURRENT D=370.792µs frac=0.90 round 1: absent=352.416µs present=333.875µs ratio=0.9x classify=FAIL
[apr1-sho…
Related
Alias · CVE (1)
- CVE-2026-88010 Traefik is an open source HTTP reverse proxy and load balancer. vulnerability
Weakness · CWE (1)
- CWE-208 Observable Timing Discrepancy weakness
References
- https://github.com/traefik/traefik/security/advisories/GHSA-8fcf-v89g-xpg6 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-88010 advisory
- https://github.com/traefik/traefik/pull/13816 web
- https://github.com/traefik/traefik/commit/ddc1bf4660b85fd61fafdd821eb8216fb1a0b130 web
- https://github.com/traefik/traefik package
- https://github.com/traefik/traefik/releases/tag/v3.7.13 web