Files
pad/internal
xarmian d86211fcdc feat(auth): per-email login rate limiter (TASK-651) (#178)
* feat(auth): per-email login rate limiter (TASK-651)

handleLogin is rate-limited per-IP (5/min, in middleware_ratelimit.go),
which is effective against a single attacker but useless against a
botnet rotating source IPs to spray one victim's password reset email.

Add a second limiter keyed on the lowercased email, 10 attempts/hour
burst 10. Consumed inside handleLogin on every attempt (success or
failure) — a legitimate user remembers their password within 1-2 tries
and never hits the limit, but an attacker pounding one account from 50
IPs is locked out after 10 attempts regardless of where those attempts
originate.

The blocked attempt is logged to the audit log as ActionLoginFailed
with reason=email_rate_limited so admins can see which accounts are
being sprayed.

Tests:
- TestHandleLogin_PerEmailRateLimit exhausts the email limit from 10
  distinct IPs, then verifies a fresh-IP attempt against the same
  email gets 429 while a different email from another fresh IP still
  gets the ordinary 401.
- TestHandleLogin_EmailCaseInsensitive verifies the limiter key is
  normalized — alternating MIXED@/mixed@/Mixed@ all count against the
  same bucket.

Parent: PLAN-643 (OSS Security Hardening).

* fix(auth): retain AuthEmail buckets for 2h per Codex P1

Codex caught that ipRateLimiter's cleanup evicts inactive keys after
30 min, which defeats the 10/hour AuthEmail budget: an attacker bursts
10, waits ~30 min for eviction, bursts another 10 — 20 guesses/hour,
not 10.

Make retention per-config, and set AuthEmail's to 2 hours (≥ 2x the
refill window) so the bucket survives the natural pause between
spraying rounds. Per-IP limiters keep the 30-min default since their
refill is sub-minute.

* fix(auth): bound AuthEmail bucket keys by plausibility per Codex P1

Codex caught that the 2-hour retention window on AuthEmail creates a
memory-DoS vector — a distributed attacker can POST many long garbage
'email' strings to /api/v1/auth/login and grow the limiter map without
bound, since each call inserts a new bucket before any email validation.

Add isPlausibleEmail() pre-filter: reject >254 chars (RFC 5321 cap) and
strings without an '@' in the interior. Only plausible emails get a
bucket; garbage still gets 401 from the password check below but never
makes it into the map.

Test: TestHandleLogin_ImplausibleEmail_NoBucketCreated hammers the
endpoint with 500-char garbage from many IPs and verifies the AuthEmail
map never holds a key starting with that garbage pattern.
TestIsPlausibleEmail covers empty, missing @, leading/trailing @, over
254, unicode local part.
2026-04-21 20:52:03 -04:00
..
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00