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:
+37
-1
@@ -112,7 +112,9 @@ async def test_successful_login_resets_the_username_bucket(client):
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
async def test_registration_is_rate_limited_per_ip(client):
|
||||
async def test_registration_is_quota_limited_per_ip(client): # B-58
|
||||
"""Five accounts per IP per hour. The sixth is told to wait, not punished with a
|
||||
backoff that doubles from there."""
|
||||
for i in range(5):
|
||||
resp = await client.post(
|
||||
"/auth/register", json={"username": f"user{i}", "password": "a-strong-password"}
|
||||
@@ -168,3 +170,37 @@ async def test_the_database_itself_rejects_a_case_variant(client):
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_failed_registrations_do_not_consume_the_quota(client): # B-58
|
||||
"""The limit is on accounts that exist, not on requests: record_failure used to
|
||||
fire on every attempt, so five signups — successful ones included — locked the
|
||||
sixth real user out for up to 600s from a shared or NAT address. Attempts that
|
||||
create nothing must leave the quota untouched."""
|
||||
await _register(client, username="taken")
|
||||
|
||||
for _ in range(10):
|
||||
resp = await client.post(
|
||||
"/auth/register", json={"username": "taken", "password": "a-strong-password"}
|
||||
)
|
||||
assert resp.status_code == 409 # username_taken, no account created
|
||||
|
||||
# Four slots left out of five, all still usable.
|
||||
for i in range(4):
|
||||
resp = await client.post(
|
||||
"/auth/register", json={"username": f"genuine{i}", "password": "a-strong-password"}
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
async def test_the_quota_reports_how_long_to_wait(client): # B-58
|
||||
for i in range(5):
|
||||
await _register(client, username=f"quotauser{i}", password="a-strong-password")
|
||||
|
||||
resp = await client.post(
|
||||
"/auth/register", json={"username": "one-too-many", "password": "a-strong-password"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 429
|
||||
retry_after = resp.json()["detail"]["params"]["retry_after_seconds"]
|
||||
assert 0 < retry_after <= 3601 # bounded by the window, not by a growing penalty
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user