soulsync/tests/test_pin_rate_limit.py
BoulderBadgeDad 0d1e949798 Security: brute-force limiter on the launch-PIN unlock (Tier 2)
A publicly-exposed instance gated only by the launch PIN was brute-forceable. Added
a lenient in-memory failed-attempt limiter (core/security/rate_limit.py): 10 wrong
PINs from one IP within 5 min → 429 with Retry-After, failures age out on their own
(self-heal, no persistent lockout), and a CORRECT entry clears that IP instantly.

Wired into /api/profiles/verify-launch-pin. By design it can only ever trigger on a
flood of WRONG PINs — correct entry, a couple of typos, or a no-PIN install are
never affected, so normal use sees no change. Keyed per-IP so an attacker can't
lock out a legit user.

Tests: limiter is lenient under threshold, trips on a flood, success clears it,
failures self-heal, per-IP isolation; endpoint returns 429 after 10 wrong PINs with
Retry-After. 6 tests pass.
2026-06-10 20:47:46 -07:00

50 lines
1.9 KiB
Python

"""Launch-PIN brute-force limiter: only a flood of WRONG PINs trips it, a correct
entry clears it instantly, and it self-heals as failures age out — so normal use
is never affected."""
from __future__ import annotations
from core.security.rate_limit import AttemptLimiter
def test_under_threshold_is_never_locked():
lim = AttemptLimiter(max_attempts=10, window_seconds=300)
for i in range(9): # 9 < 10 → never locked
lim.record_failure('1.2.3.4', now=1000 + i)
locked, _ = lim.is_locked('1.2.3.4', now=1010)
assert locked is False
def test_flood_trips_the_lock_with_retry_after():
lim = AttemptLimiter(max_attempts=10, window_seconds=300)
for i in range(10):
lim.record_failure('1.2.3.4', now=1000 + i)
locked, retry_after = lim.is_locked('1.2.3.4', now=1010)
assert locked is True
assert retry_after > 0
def test_success_clears_immediately():
lim = AttemptLimiter(max_attempts=10, window_seconds=300)
for i in range(10):
lim.record_failure('1.2.3.4', now=1000 + i)
assert lim.is_locked('1.2.3.4', now=1010)[0] is True
lim.record_success('1.2.3.4') # correct PIN
assert lim.is_locked('1.2.3.4', now=1011)[0] is False
def test_failures_age_out_self_heal():
lim = AttemptLimiter(max_attempts=10, window_seconds=300)
for i in range(10):
lim.record_failure('1.2.3.4', now=1000 + i)
assert lim.is_locked('1.2.3.4', now=1010)[0] is True
# well past the window → all failures expired → unlocked
assert lim.is_locked('1.2.3.4', now=2000)[0] is False
def test_per_ip_isolation():
lim = AttemptLimiter(max_attempts=10, window_seconds=300)
for i in range(10):
lim.record_failure('attacker', now=1000 + i)
assert lim.is_locked('attacker', now=1010)[0] is True
assert lim.is_locked('legit-user', now=1010)[0] is False # not punished for someone else