Skip to content

fix: PIN brute-force defense — IP-scoped backoff + close user-existence oracle#73

Merged
windoze95 merged 1 commit into
mainfrom
fix/backend-pin-ip-backoff
Jun 28, 2026
Merged

fix: PIN brute-force defense — IP-scoped backoff + close user-existence oracle#73
windoze95 merged 1 commit into
mainfrom
fix/backend-pin-ip-backoff

Conversation

@windoze95

Copy link
Copy Markdown
Owner

Hardens PIN brute-force defense (roadmap LATER tier, #11). Additive — no schema/migration.

Problem

PIN lockout was keyed by the unauthenticated user_id from the request, so anyone reachable could repeatedly fail PINs to lock a specific household member out (account-lockout DoS). /select also returned 404 for missing users, making it a user-existence oracle.

Fix

  • Throttle keyed by client IP + target user_id, so an attacker's IP can't lock out the victim's profile; a shared device can still switch profiles while one is backed off. Per-user entries cleared on PIN change/delete.
  • Exponential backoff: first lockout at the 5th failure (30s), doubling (60s/120s/…) capped at 1h; failure count persists across expiry; idle keys forgotten after 1h; memory bounded under IP churn. 429 now carries Retry-After.
  • Client IP: request.client.host (unspoofable socket peer) by default; opt-in TRUST_PROXY_HEADERS uses the rightmost X-Forwarded-For hop (client-forgeable leftmost entries never trusted).
  • Oracle closed: a missing user is checked against a precomputed dummy scrypt hash, so it costs the same work and returns an identical 403 as a real wrong-PIN attempt; repeated probes back off identically.
  • Kept in-process per the no-live-Redis constraint, with a TODO to move counters to the existing Redis for multi-worker/restart persistence.

Verification (local)

  • pytest -q155 passed (+ test_pin_throttle.py 9 tests; test_pin_lockout_is_scoped_to_client_ip, test_select_is_not_a_user_existence_oracle, and the 429-burst test updated with a Retry-After assertion).
  • ruff + mypy clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RXMKM1rDWn8wNh93MMUtxY

PIN lockout was keyed by the unauthenticated user_id from the request, so
anyone who knew a user_id (all of which /profiles lists) could fail PINs to
lock a specific household member out -- an account-lockout DoS -- and the
differing 404/403 responses leaked whether a user existed.

- Rate-limit PIN attempts per (client IP, user_id) with exponential backoff
  (30s, doubling, capped at 1h) instead of a flat per-user lock. Attacker and
  victim have different IPs, so an attacker can no longer lock out the victim;
  a shared device can still switch to another profile while one is backed off.
- Derive the client IP from the socket peer by default; honor X-Forwarded-For
  (rightmost hop) only when the new trust_proxy_headers setting is enabled.
- Make /select uniform whether or not the user exists: a missing user is
  verified against a dummy scrypt hash and returns the same 403 + timing as a
  wrong PIN (no more 404 "User not found"), and repeated probes back off too.
- Emit Retry-After on 429.

Throttle state stays in-process (no Redis needed for tests/local dev); a TODO
notes moving it to the existing Redis for multi-worker + restart persistence.

Tests: per-IP isolation, exponential backoff/cap, oracle indistinguishability
(wrong PIN vs unknown user, with and without a PIN), reset-on-success and
reset-on-PIN-change, plus client-IP/XFF derivation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMKM1rDWn8wNh93MMUtxY
@windoze95 windoze95 merged commit 4e89139 into main Jun 28, 2026
5 checks passed
@windoze95 windoze95 deleted the fix/backend-pin-ip-backoff branch June 28, 2026 02:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant