feat: throttle failed logins per client IP

PR C of SECURITY_PLAN.md: after 5 failed /login attempts an IP is refused
for 30 s, and each failure after that refuses it for twice as long, up
to 15 min. A success resets it, as does an hour without a failure.

- LoginThrottle.h holds the schedule and 8 IPs, with the time passed in
  so native tests cover it. While all 8 hold recent failures, every
  other IP shares one more slot, so many addresses don't each get five
  free tries.
- A refused IP gets 429 with Retry-After before its credentials are
  checked, so a right guess during a lockout reveals nothing. Home
  Assistant retries a 429 instead of asking for new credentials.
- The web UI's login panel shows a 401's or 429's message, where it
  popped a service error.
- test/e2e --lockout checks it on a Secured device and waits it out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-26 22:08:30 +01:00
parent 1612fd1587
commit bab83313ee
8 changed files with 326 additions and 1 deletions

View file

@ -56,6 +56,10 @@
### Login throttling (PR C)
- Per client IP: after 5 failed `/login` attempts, refuse that IP for 30 s, doubling each time up to 15 min. A success resets it.
- After the first lockout, each failure starts the next one, so an IP gets one try per lockout rather than five.
- An hour without a failure forgets the IP.
- A refused IP gets 429 with `Retry-After`, before its credentials are checked. Home Assistant tries again later instead of asking for new credentials.
- The device keeps 8 IPs ([src/LoginThrottle.h](../src/LoginThrottle.h)). While all of them hold recent failures, every other IP shares one more slot. That way many addresses don't each get five free tries, but an unknown IP may have to wait during such an attack.
- Only `/login` is throttled. After PR B a token is a 256-bit HMAC with a secret key, so guessing one through `apikey` is hopeless.
### One server on port 80 ([ADR 0006](adr/0006-two-security-modes-eleven-http-routes.md))