/** * Regression guard — the door must not close on a venue behind one NAT. * * `/join` was throttled 5 per 60s keyed purely on the client IP. Every guest at a venue * arrives from the same public IP (that is what a NAT is), so the whole party shared one * bucket: 12 guests scanning the QR code within a few seconds meant 5 got in and 7 were * turned away — with no Retry-After to tell them when to try again. `/feed` (60/min) and * `/export` (3/DAY) had the identical defect. * * These ran green for the same structural reason every time: the e2e reseed forces every * limiter toggle OFF before each test, so nothing here was ever exercised. Enable them * explicitly, exactly as 02-upload/rate-limit does. */ import { test, expect } from '../../fixtures/test'; import { BASE } from '../../helpers/env'; test.describe('Rate limits — guests behind a shared NAT', () => { test('a dozen guests can all join from one IP, and 429s carry Retry-After', async ({ api, adminToken, }) => { await api.patchConfig(adminToken, { rate_limits_enabled: 'true', join_rate_enabled: 'true', }); // Twelve DISTINCT guests, same source IP — the arrival burst at a real party. const names = Array.from({ length: 12 }, (_, i) => `NatGuest${i}`); const results = await Promise.all( names.map((display_name) => fetch(`${BASE}/api/v1/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name }), }) ) ); const rejected = results.filter((r) => r.status === 429); expect( rejected.length, `all 12 guests must get in from one IP; ${rejected.length} were turned away` ).toBe(0); expect(results.every((r) => r.status === 201)).toBe(true); }); test('one guest retrying their own name is still throttled, and told for how long', async ({ api, adminToken, }) => { // The per-name bucket must still bite — otherwise the NAT fix would have simply // removed the anti-spam limit rather than re-keyed it. await api.patchConfig(adminToken, { rate_limits_enabled: 'true', join_rate_enabled: 'true', }); const attempt = () => fetch(`${BASE}/api/v1/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: 'RepeatOffender' }), }); // 5 per 60s for the same (ip, name): the first succeeds (201), the next four collide // with the taken name (409), and the sixth exhausts the bucket. const codes: number[] = []; for (let i = 0; i < 6; i++) codes.push((await attempt()).status); expect(codes[0], 'the first join should succeed').toBe(201); expect(codes.at(-1), 'the 6th attempt on one name must be throttled').toBe(429); const throttled = await attempt(); expect(throttled.status).toBe(429); const retryAfter = throttled.headers.get('retry-after'); expect(retryAfter, '429 must tell the client when to come back').toBeTruthy(); expect(Number(retryAfter)).toBeGreaterThan(0); expect(Number(retryAfter)).toBeLessThanOrEqual(60); }); test('the feed limit is per-user, not per-IP', async ({ api, adminToken, guest }) => { // Two guests, one IP. With a limit of 3/min an IP key would let the first guest's // three reads starve the second entirely. await api.patchConfig(adminToken, { rate_limits_enabled: 'true', feed_rate_enabled: 'true', feed_rate_per_min: '3', }); const a = await guest('FeedHog'); const b = await guest('FeedVictim'); const read = (jwt: string) => fetch(`${BASE}/api/v1/feed`, { headers: { Authorization: `Bearer ${jwt}` } }); // Guest A burns their whole allowance. for (let i = 0; i < 3; i++) expect((await read(a.jwt)).status).toBe(200); expect((await read(a.jwt)).status, "A's own 4th read is throttled").toBe(429); // Guest B must be entirely unaffected. expect((await read(b.jwt)).status, 'B must not inherit A’s exhausted bucket').toBe(200); }); test('the export limit is per-user — one guest cannot spend the whole venue’s quota', async ({ api, adminToken, guest, host, db, }) => { // The sharpest case: 3 downloads per DAY on an IP key meant the 4th guest to fetch // their keepsake was locked out until tomorrow. await db.setExportReleased('e2e-test-event', true); await api.patchConfig(adminToken, { rate_limits_enabled: 'true', export_rate_enabled: 'true', export_rate_per_day: '1', }); const mintAndFetch = async (jwt: string) => { const res = await fetch(`${BASE}/api/v1/export/ticket`, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` }, }); const { ticket } = await res.json(); return fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`); }; const a = await guest('ExportFirst'); const b = await guest('ExportSecond'); // A spends their single daily allowance. The archive itself may not exist (404) — // what matters is that the limiter admitted the request rather than 429ing it. expect((await mintAndFetch(a.jwt)).status).not.toBe(429); expect((await mintAndFetch(a.jwt)).status, 'A’s second download is throttled').toBe(429); // B shares A's IP and must still get their keepsake. expect((await mintAndFetch(b.jwt)).status, 'B must not be locked out by A’s download').not.toBe( 429 ); // And the host too, for good measure. expect((await mintAndFetch(host.jwt)).status).not.toBe(429); }); }); test.describe('Rate limits — /recover name cycling', () => { test('cycling names from one IP hits the ceiling, while one name is still throttled', async ({ api, adminToken, }) => { // /recover is keyed `recover:{ip}:{name}` — right for its job (stopping someone who // knows a display name from burning the victim's 3-strike PIN counter), but the name is // ATTACKER-CHOSEN, so cycling names minted a fresh bucket every time. Behind it sits a // cost-12 bcrypt verify, including an unconditional throwaway one for unknown names, so // a name generator was the cheapest way to make the server hash forever. // // Squeeze the ceiling so the flood is reproducible without firing 30+ requests. await api.patchConfig(adminToken, { rate_limits_enabled: 'true', recover_rate_enabled: 'true', recover_ip_rate_per_min: '5', }); const attempt = (name: string) => fetch(`${BASE}/api/v1/recover`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: name, pin: '0000' }), }); // Every name is distinct, so the per-name bucket can never fire — only the ceiling can. const codes: number[] = []; for (let i = 0; i < 12; i++) codes.push((await attempt(`Unbekannt${i}_${Date.now()}`)).status); expect( codes.filter((c) => c === 429).length, 'name cycling must be capped by the per-IP ceiling' ).toBeGreaterThan(0); const throttled = await attempt(`Unbekannt99_${Date.now()}`); expect(throttled.status).toBe(429); expect(Number(throttled.headers.get('retry-after'))).toBeGreaterThan(0); }); test('the per-name bucket still protects a real account', async ({ api, adminToken, guest }) => { // The ceiling must not have REPLACED the anti-guessing control. With a generous ceiling, // repeated wrong PINs against ONE name must still be shut down by the per-name bucket. const victim = await guest('PinVictim'); await api.patchConfig(adminToken, { rate_limits_enabled: 'true', recover_rate_enabled: 'true', recover_ip_rate_per_min: '1000', }); const attempt = () => fetch(`${BASE}/api/v1/recover`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: victim.displayName, pin: '9999' }), }); const codes: number[] = []; for (let i = 0; i < 7; i++) codes.push((await attempt()).status); expect(codes.at(-1), 'guessing one name must still be throttled').toBe(429); }); });