fix(rate-limit): key the guest-facing limiters per user, not per IP
At a venue every guest is behind one NAT, so an IP-keyed limiter hands the
whole party a single bucket. On a fresh deploy 12 guests arriving together
meant 5 joined and 7 were turned away, with no Retry-After telling them when
to retry. `/feed` (60/min) and `/export` (3 per DAY — the fourth guest to
fetch their keepsake locked out until tomorrow) had the same defect.
`feed_delta` was already keyed per-user and its comment states the exact
rationale ("so one client can't starve others behind a shared NAT"); this
makes its siblings match.
- feed:{ip} -> feed:{user_id} (auth was already in scope)
- export:{ip} -> export:{user_id} (resolved from the download ticket's
session, which was previously looked up and discarded)
- join:{ip}: pre-auth, so there is no user to key on. Split in two — a loose
per-IP ceiling that only bounds raw volume (new `join_ip_rate_per_min`,
default 60, migration 017), plus the real 5/60s anti-spam bucket keyed
per (ip, name), mirroring the existing `recover:{ip}:{name}`.
admin_login / recover / pin_reset_req stay IP-keyed on purpose and are now
commented as such: they guard credential guessing, where a per-user or
per-name key would just hand an attacker a fresh bucket per guess.
Retry-After: the machinery existed but 7 of 8 sites called `check()` and
hard-coded `None`, so a throttled client was told to back off but never for
how long. Delete the bool `check()` wrapper entirely so `check_with_retry`
is the only entry point and the delay cannot be discarded by accident. Also
surface it for the PIN lockout, where the deadline was already known.
Fix the "unknown" fallback while here: every client_ip() caller passed that
literal, so any request without X-Forwarded-For — anything reaching the app
directly rather than through Caddy — shared ONE global bucket. Serve with
connect-info and use the peer address.
Tests: the reseed forces every limiter toggle off before each test, which is
why this whole class was invisible. Add 01-auth/rate-limit-shared-nat, which
enables them and asserts 12 guests share an IP without collision, that one
guest hammering their own name IS still throttled (so the fix re-keys rather
than removes the limit), and that feed/export buckets are per-user. Retarget
the ddos join test at the new per-IP ceiling — it asserted the defect.
Also seed `admin_login_rate_enabled` (read by the handler, seeded by no
migration and no reseed) and register `join_ip_rate_per_min` in the admin
config allowlist. Unrelated pre-existing red test fixed: 01-auth/join
asserted a "Willkommen!" heading the wedding redesign removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,15 @@ test.describe('Adversarial — small-scale abuse', () => {
|
||||
await api.patchConfig(adminToken, { rate_limits_enabled: 'true', join_rate_enabled: 'true' });
|
||||
});
|
||||
|
||||
test('20 parallel /join from one IP — rate limiter catches the excess', async () => {
|
||||
test('a /join flood from one IP is caught by the per-IP ceiling', async ({ api, adminToken }) => {
|
||||
// This used to assert that 20 joins from one IP produced 429s under a 5/min per-IP
|
||||
// bucket. That "protection" was the bug: at a venue every guest shares one public IP,
|
||||
// so it turned real arriving guests away (see 01-auth/rate-limit-shared-nat). The
|
||||
// anti-spam bucket is now per (ip, name); what remains per-IP is a loose ceiling whose
|
||||
// job is only to bound raw volume. Squeeze the ceiling so a flood is reproducible here
|
||||
// without firing 60+ requests.
|
||||
await api.patchConfig(adminToken, { join_ip_rate_per_min: '5' });
|
||||
|
||||
const requests = Array.from({ length: 20 }, (_, i) =>
|
||||
fetch(`${BASE}/api/v1/join`, {
|
||||
method: 'POST',
|
||||
@@ -24,7 +32,7 @@ test.describe('Adversarial — small-scale abuse', () => {
|
||||
})
|
||||
);
|
||||
const statuses = (await Promise.all(requests)).map((r) => r.status);
|
||||
// 5/min limit → at least some should be 429.
|
||||
// Ceiling of 5 → the excess must be shed.
|
||||
expect(statuses.filter((s) => s === 429).length).toBeGreaterThan(0);
|
||||
// Server stays up — at least one succeeded.
|
||||
expect(statuses.some((s) => s === 201 || s === 409)).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user