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
+37 -1
View File
@@ -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