fix(api): stop rate limit bypass via spoofed X-Forwarded-For and close login timing oracle#286
Conversation
…e login timing oracle Rate limiting keyed on X-Forwarded-For unconditionally, so a client could send a unique value on every request and get a fresh limiter bucket each time, fully bypassing the only brute-force protection on login. It now only trusts X-Forwarded-For when the direct connection comes from a configured trusted proxy, falling back to the connection address otherwise. New trusted_proxies option documents the opt-in. Login also skipped the password hash comparison entirely when a username didn't exist, making the response time a reliable oracle for enumerating valid accounts (measured ~45ms delta between the two paths). It now runs the same bcrypt comparison against a fixed dummy hash on a lookup miss.
…action Review follow-ups on the login brute-force protections. Timing oracle was only closed in one direction. GitHub-sourced accounts store an empty password hash, so bcrypt rejects them on a length check in microseconds while a non-existent username now pays a full compare - the same enumeration attack, inverted. Every login attempt now costs exactly one bcrypt comparison: a missing user or an unusable hash falls back to a fixed dummy hash, and a separate hasCredential flag (not the comparison result) decides whether authentication can succeed, so knowing the dummy password can't log anyone in. The dummy hash is now a pre-generated constant instead of being derived during package init, so binaries that never serve a login - `benchmarkoor run` - don't pay a bcrypt derivation at startup, and there's no panic path. X-Forwarded-For is now walked right-to-left to the first hop that isn't itself a trusted proxy, rather than taking the right-most entry. With a chain of proxies (a CDN in front of a load balancer) the right-most entry is our own infrastructure, which keyed every client onto one bucket and locked the whole user base out at the auth tier. Each candidate hop must parse as an IP, so a proxy that forwards the client header verbatim can no longer mint a fresh limiter key per request out of arbitrary strings. Skipped trusted_proxies entries are logged instead of silently dropped (a typo demotes a real proxy to untrusted), the list is parsed once on the server rather than per middleware tier, and the server warns once if it sees X-Forwarded-For with no trusted proxies configured - the silent regression for anyone already running behind a proxy. The timing regression guard now compares the failure paths against each other instead of asserting an absolute 5ms floor, which passed for any implementation that was merely slow. Verified it fails (21us vs 47ms baseline) when the passwordless-user fix is reverted.
|
Reviewed this and pushed follow-ups in 21f0ff1 rather than leaving a pile of inline comments — both fixes were directionally right, but each had a residual hole. Summary of what changed and why: Timing oracle was only closed in one directionGitHub-sourced accounts store an empty password hash (
The dummy hash is also now a pre-generated constant instead of being derived in a package-level Right-most X-Forwarded-For hop breaks with more than one proxyTaking
Anything used as a limiter key must parse as an IPThe trusted branch returned the trimmed header value verbatim. A trusted proxy that forwards the client's header instead of appending to it ( Operability
Test guard was passing for the wrong reason
Not addressed (pre-existing, out of scope here)A DB error from |
"unparseable" -> "unparsable" in the extractIP malformed-hop assertion message.
What
Two issues in the login path:
The rate limiter keyed on X-Forwarded-For unconditionally. A client could send a unique value on every request and get a fresh limiter bucket each time, fully bypassing the only brute-force protection on login. Verified live: 20 login attempts with a unique spoofed XFF per request all returned 401, never 429.
Login skipped the password hash comparison entirely when a username did not exist, making response time a reliable oracle for enumerating valid accounts. Measured live: ~45ms delta between an existing user (wrong password) and a nonexistent user, with zero overlap across 120 samples.
How
trusted_proxiesentry (IP or CIDR), falling back to the connection address otherwise. Unset means XFF is never trusted, so this is safe by default with no config changes required.Test plan
go test -race ./pkg/api/...passes,go vetandgofmtclean.