Files
EventSnap/e2e/specs/07-adversarial/ddos.spec.ts
fabi 89057d605f 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>
2026-07-27 21:56:49 +02:00

128 lines
5.9 KiB
TypeScript

/**
* Phase 2 adversarial — small-scale DDoS / oversized-body tests. These are
* NOT real load tests. We just verify that obvious abuse is rate-limited
* or rejected gracefully without crashing the backend.
*/
import { test, expect } from '../../fixtures/test';
import { mintSseTicket } from '../../helpers/sse';
import { seedUpload } from '../../helpers/seed';
import { BASE } from '../../helpers/env';
test.describe('Adversarial — small-scale abuse', () => {
// Note: the truncate auto-fixture resets every rate-limit toggle back to false
// before each test, so we re-enable in beforeEach (not beforeAll).
test.beforeEach(async ({ api, adminToken }) => {
await api.patchConfig(adminToken, { rate_limits_enabled: 'true', join_rate_enabled: 'true' });
});
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: `Flood${i}_${Date.now()}` }),
})
);
const statuses = (await Promise.all(requests)).map((r) => r.status);
// 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);
});
// The comment body cap lives in [backend/src/handlers/social.rs] `add_comment`:
// if text_chars == 0 || text_chars > 500 → 400
// It must be exercised against a REAL upload: the handler looks the upload up (and
// 404s) *before* it reaches the length check, so posting to a non-existent id proves
// nothing about the cap.
async function postComment(jwt: string, uploadId: string, body: string) {
return fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
});
}
test('comment body over the 500-char cap is rejected with 400', async ({ guest }) => {
const g = await guest('LongComment');
const uploadId = await seedUpload(g.jwt);
const res = await postComment(g.jwt, uploadId, 'A'.repeat(501));
expect(res.status, '501 chars must be rejected by the length cap').toBe(400);
const json: any = await res.json().catch(() => ({}));
expect((json.message ?? '').toLowerCase()).toMatch(/500 zeichen/);
});
test('comment body exactly at the 500-char cap is accepted', async ({ guest }) => {
const g = await guest('MaxComment');
const uploadId = await seedUpload(g.jwt);
// The boundary must be inclusive — otherwise the "cap" is really 499 and the
// rejection test above would also pass on an off-by-one implementation.
const res = await postComment(g.jwt, uploadId, 'A'.repeat(500));
expect(res.status, '500 chars is the documented maximum and must be accepted').toBe(201);
});
test('10 MB comment body never reaches the handler (body-size limit rejects it)', async ({
guest,
}) => {
const g = await guest('BigComment');
const uploadId = await seedUpload(g.jwt);
const huge = 'A'.repeat(10 * 1024 * 1024);
const res = await postComment(g.jwt, uploadId, huge);
// This asserts ONLY what it can prove: a 10 MB JSON body is refused somewhere on the
// path (Caddy's request-body limit → 502/413, or the backend's own body limit → 413,
// or the 500-char cap if it does get through → 400). The upload exists, so a 404 here
// would be a bug, and a 201 would mean we stored a 10 MB comment.
expect([400, 413, 502]).toContain(res.status);
});
test('SSE: 10 concurrent streams from one user do not crash the server', async ({ guest }) => {
const g = await guest('SseFlood');
// The stream endpoint authenticates via single-use tickets (POST /stream/ticket),
// not the raw JWT — a `?token=` open is rejected with 400. Mint one ticket per stream.
// (These streams must be held open concurrently, so we can't use the openStream
// helper which opens-and-aborts a single stream.)
const tickets = await Promise.all(Array.from({ length: 10 }, () => mintSseTicket(g.jwt)));
const controllers = tickets.map(() => new AbortController());
const requests = tickets.map((ticket, i) =>
fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, {
signal: controllers[i].signal,
})
);
const responses = await Promise.all(requests);
// All accepted (or some rate-limited — both fine).
for (const r of responses) {
expect([200, 429]).toContain(r.status);
}
// Tear them all down so the next test doesn't see leaked connections.
controllers.forEach((c) => c.abort());
// Sanity: a new request still works.
const ping = await fetch(`${BASE}/api/v1/me/context`, {
headers: { Authorization: `Bearer ${g.jwt}` },
});
expect(ping.status).toBe(200);
});
test('malformed JSON in /join is rejected with 400, not 500', async () => {
const res = await fetch(`${BASE}/api/v1/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"display_name":',
});
expect([400, 422]).toContain(res.status);
expect(res.status).not.toBe(500);
});
});