With TRUSTED_PROXIES empty behind the reverse proxy production is
required to run behind, every login POST keyed on the proxy's address
and shared one 5/minute bucket. A stranger sending five POSTs a
minute -- 0.08 requests per second, from anywhere -- kept that bucket
permanently full, and the operator's own correct password was answered
429 indefinitely with no second administrative path.
The login POST no longer has a pre-emptive limiter. The handler
verifies credentials first and spends budget only on a FAILED attempt,
so a correct password is never throttled whatever the counters hold.
Three things follow, and are implemented together because the first is
unsafe without the other two:
- Failures are counted per (client bucket, submitted username), five
per minute, after which further failures get 429 with a Retry-After.
A successful login clears the counter, so mistyping and then
succeeding does not leave the operator throttled.
- Both key sets are capped at 1024 entries. The submitted username is
attacker-controlled, so past the first cap failures fall back to a
counter keyed on the client alone, and past both caps a failure is
answered as throttled without being recorded. Tracked state stays
under half a megabyte and does not grow with invented usernames.
- Concurrent Argon2id verifications are capped at two, a 128 MB
ceiling at 64 MB per hash. Every password-hashing endpoint takes a
slot, including the password-change endpoint, which holds one across
both its hashes. A request that waits five seconds without a slot is
answered 503 and no hash runs for it.
An unknown username is verified against a dummy hash instead of
returning early, so a nonexistent account costs the same time as a
real one and the response cannot be used to enumerate usernames.
The password-change limiter is unchanged: RequireAuth runs ahead of
it, so only a request already carrying a valid session reaches its
bucket.
Also adds the missing test for the third bucketKey call site, where
the peer is a trusted proxy but the forwarded chain names no client.
Every existing test of that fallback uses an IPv4 proxy, where
bucketKey is the identity function, so dropping the /64 masking there
left the suite green.
README and the TRUSTED_PROXIES startup warning updated: a shared
bucket now costs precision, not the availability of the admin path.