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.
50 lines
1.9 KiB
Python
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
|