Bound registrations with a per-IP quota instead of failure backoff (B-58)

The registration throttle called record_failure on every attempt, successful
ones included. Five legitimate signups from one shared or NAT address locked the
sixth real user out for up to 600s, doubling from there — while an attacker
sidestepped the limiter entirely through B-54. Failure backoff is the wrong
instrument here: nothing about creating an account is a failed guess at a
secret, so the only people it reliably punished were the honest ones.

RollingQuota says what was actually meant: 5 accounts per IP per hour, in a
rolling window. The caller over it waits exactly until the oldest of the five
ages out — an accurate Retry-After, and waiting never makes the next wait
longer. It is recorded only once an account exists, so attempts that create
nothing (a taken username, a validation error) leave the quota untouched, and
checked before the Argon2 hash, so an IP out of quota costs nothing to refuse.

Bounded like the failure limiter (B-56): the keys are caller-chosen, so the dict
gets both a sweep and a hard cap, evicting keys with room left in their quota
before full ones.

Also fixes the inline comment that cited B-31 (the resubscribe finding) where it
meant B-33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 22:32:20 +02:00
co-authored by Claude Opus 5
parent 57721355f0
commit 0aac73e557
6 changed files with 183 additions and 25 deletions
+50 -1
View File
@@ -10,7 +10,7 @@ matters here is that pruning never hands an attacker a free pass.
import time
from app.auth.rate_limit import RateLimiter
from app.auth.rate_limit import RateLimiter, RollingQuota
def _limiter(**kwargs) -> RateLimiter:
@@ -89,3 +89,52 @@ def test_pruning_does_not_reset_a_live_failure_count():
limiter.record_failure("user:a")
assert limiter.retry_after("user:a") > 0
# --- B-58: registration is a quota, not failure backoff -------------------------
def test_quota_allows_up_to_the_limit_then_asks_for_a_wait():
quota = RollingQuota(limit=3, window_seconds=60.0)
for _ in range(3):
assert quota.retry_after("ip:1") == 0.0
quota.record("ip:1")
wait = quota.retry_after("ip:1")
assert 0 < wait <= 60.0
def test_quota_is_per_key():
quota = RollingQuota(limit=1, window_seconds=60.0)
quota.record("ip:1")
assert quota.retry_after("ip:1") > 0
assert quota.retry_after("ip:2") == 0.0
def test_quota_frees_a_slot_once_the_oldest_event_leaves_the_window():
"""The point of a rolling window over failure backoff: the caller waits exactly
until there's room again, and waiting doesn't make the next wait longer."""
quota = RollingQuota(limit=2, window_seconds=0.05)
quota.record("ip:1")
quota.record("ip:1")
assert quota.retry_after("ip:1") > 0
time.sleep(0.06)
assert quota.retry_after("ip:1") == 0.0
def test_quota_prunes_spent_keys_and_caps_its_dict():
"""Same bound as the failure limiter (B-56): caller-chosen keys, so both a sweep
and a hard cap."""
quota = RollingQuota(limit=1, window_seconds=0.05, sweep_interval_seconds=0.0)
for i in range(50):
quota.record(f"ip:{i}")
time.sleep(0.06)
quota.record("ip:fresh")
assert list(quota._events) == ["ip:fresh"]
capped = RollingQuota(limit=1, window_seconds=3600.0, max_keys=10, sweep_interval_seconds=0.0)
for i in range(200):
capped.record(f"ip:{i}")
assert len(capped._events) <= 11