Files
EventSnap/e2e/specs/07-adversarial/auth-tampering.spec.ts
fabi 32dfe6874a test(e2e): make nine red specs assert the contracts the code actually implements
The e2e suite had never been run during this audit. It failed 9 of 256; seven of those
predated the audit's changes, established by building a stack from a clean HEAD worktree
and running the same specs against it rather than guessing.

Most were stale assertions rather than product defects:

- quota.spec solved for a target limit using the observed uploader count, but the divisor is
  max(active, estimated_guest_count, 1) and that config seeds at 100 — so every limit it
  aimed for came out 100x small and every "within quota" upload 413'd.
- rate-limit-shared-nat destructured `ticket` from a 429 body and fetched with
  `ticket=undefined`, turning the 429 under test into an unrelated 401. It also faked a
  release with no archive on disk, so the mint's pre-check 404'd and the per-day limiter was
  never reached; it now does a real release and asserts 200 rather than "not 429".
- ddos allowed only [200,429] from ten concurrent streams, so it failed on the very defence
  it exercises: four tickets per session survive and the rest correctly 401. Now asserts
  exactly four, which a tightened cap or an inverted eviction order would catch.
- auth-tampering asserted a throttled IP is refused EVEN with the correct password. That
  contract was deliberately removed — it let any phone on the venue NAT lock the operator
  out of their own admin panel, with a circular escape hatch. Inverted, plus a new check
  that a success does not refill an attacker's bucket.
- moderation-ui assumed a ban leaves a comment "stuck on screen"; `list_for_upload` filters
  banned authors, so it is hidden from everyone including the host. Now pins the pair that
  matters — the ban hides it, and the host's permanent removal survives an unban — and the
  UI leg it used to own is restored as a separate test on a reachable comment.

The export specs mint with `?kind=` now that a download ticket is bound to one archive, and
four of them assert the mint's 404 rather than the download's: with the kind always known,
the pre-check refuses up front instead of after charging a daily download for an archive
that cannot be served.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:44:48 +02:00

325 lines
14 KiB
TypeScript

/**
* Phase 2 adversarial — JWT forgery, brute-force, and password attacks.
*
* The JWT secret is in docker-compose.test.yml as a fixed value — these
* tests do NOT try to forge tokens using that secret (that would only
* prove HS256 works). Instead they assert the *failure* paths: alg:none,
* tampered signature, expired sessions, wrong role.
*/
import { test, expect } from '../../fixtures/test';
import { ADMIN_PASSWORD } from '../../fixtures/api-client';
import { BASE } from '../../helpers/env';
/** RFC-4648 base64url with no padding. */
function b64u(s: string) {
return Buffer.from(s)
.toString('base64')
.replace(/=+$/, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
test.describe('Adversarial — JWT', () => {
test('alg:none token claiming admin role is rejected', async () => {
const header = b64u(JSON.stringify({ alg: 'none', typ: 'JWT' }));
const payload = b64u(
JSON.stringify({
sub: '00000000-0000-0000-0000-000000000000',
role: 'admin',
event_id: '00000000-0000-0000-0000-000000000000',
exp: Math.floor(Date.now() / 1000) + 3600,
})
);
const token = `${header}.${payload}.`;
const res = await fetch(`${BASE}/api/v1/admin/config`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status).toBe(401);
});
test('JWT with valid structure but bogus signature is rejected', async ({ guest }) => {
const g = await guest('SigForge');
const parts = g.jwt.split('.');
// Replace the signature with random bytes of the same length.
const fakeSig = parts[2].split('').reverse().join('');
const tampered = `${parts[0]}.${parts[1]}.${fakeSig}`;
const res = await fetch(`${BASE}/api/v1/me/context`, {
headers: { Authorization: `Bearer ${tampered}` },
});
expect(res.status).toBe(401);
});
test('JWT with payload-tampered role=admin (re-encoded payload, original signature) is rejected', async ({
guest,
}) => {
const g = await guest('RolePromote');
const parts = g.jwt.split('.');
const original = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
const escalated = { ...original, role: 'admin' };
const newPayload = b64u(JSON.stringify(escalated));
const tampered = `${parts[0]}.${newPayload}.${parts[2]}`;
const res = await fetch(`${BASE}/api/v1/admin/config`, {
headers: { Authorization: `Bearer ${tampered}` },
});
// Signature won't match the new payload → middleware must return 401, not 403.
expect(res.status).toBe(401);
});
test('JWT for a session that was deleted (logout) is rejected', async ({ guest, api }) => {
const g = await guest('LoggedOut');
await api.logout(g.jwt);
const res = await fetch(`${BASE}/api/v1/me/context`, {
headers: { Authorization: `Bearer ${g.jwt}` },
});
expect(res.status).toBe(401);
});
test('Authorization header without "Bearer " prefix is rejected', async ({ guest }) => {
const g = await guest('NoBearer');
const res = await fetch(`${BASE}/api/v1/me/context`, {
headers: { Authorization: g.jwt },
});
expect([401, 403]).toContain(res.status);
});
test('missing Authorization header on protected route returns 401', async () => {
const res = await fetch(`${BASE}/api/v1/me/context`);
expect(res.status).toBe(401);
});
});
test.describe('Adversarial — PIN brute-force', () => {
/**
* The PIN defence has two tiers, and telling them apart is the whole point of these tests:
*
* - the per-(IP, name) throttle, which refuses the ATTACKER; and
* - the account lock, which refuses the VICTIM — the only tier that can be weaponised.
*
* Both answer 429, so the status code alone proves nothing. The regression these guard is that
* the lock threshold used to sit BELOW the throttle ceiling (3 vs 5), so three requests from a
* single IP locked any guest whose display name is readable off the feed, every 15 minutes,
* indefinitely. The tier meant to protect a guest was the cheapest way to attack them.
*/
// Restore the default. The first test turns the limiter on for the whole instance, and leaving
// it on would throttle unrelated specs sharing this stack. Runs even on failure.
test.afterEach(async ({ api, adminToken }) => {
await api.patchConfig(adminToken, { rate_limits_enabled: 'false' });
});
test('a single IP is throttled without ever locking the victim out', async ({
api,
adminToken,
guest,
db,
}) => {
// Rate limits are off by default in this environment, and the throttle IS the tier under
// test — without it the run would silently assert only half the property.
await api.patchConfig(adminToken, {
rate_limits_enabled: 'true',
recover_rate_enabled: 'true',
});
const g = await guest('Brute');
const wrong = g.pin === '0000' ? '1111' : '0000';
// Serially, so the failed-PIN counter increments monotonically. Well past the per-(IP, name)
// ceiling of 4, and past the OLD lock threshold of 3.
const statuses: number[] = [];
for (let i = 0; i < 8; i++) {
const r = await fetch(`${BASE}/api/v1/recover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: g.displayName, pin: wrong }),
});
statuses.push(r.status);
}
expect(
statuses.filter((s) => s === 200),
'a wrong PIN must never authenticate'
).toHaveLength(0);
expect(
statuses.some((s) => s === 429),
'the attacker must be throttled'
).toBe(true);
expect(
await db.isPinLocked(g.userId),
'one IP must not be able to lock a guest out of their own account'
).toBe(false);
});
test('the account still locks once the failure count is reached', async ({ guest, db }) => {
const g = await guest('BruteDistributed');
const wrong = g.pin === '0000' ? '1111' : '0000';
// The per-IP throttle is what a single source hits first, so drive the counter the way a
// DISTRIBUTED attacker would — the tier this test covers is the last line against exactly
// that, and it must not have been removed while fixing the weaponisation above.
await db.setFailedPinAttempts(g.userId, 11);
const r = await fetch(`${BASE}/api/v1/recover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.77' },
body: JSON.stringify({ display_name: g.displayName, pin: wrong }),
});
expect(r.status).toBe(401);
expect(
await db.isPinLocked(g.userId),
'a distributed guesser must still trip the account lock'
).toBe(true);
// And the lock holds even against the correct PIN, which is what makes it a real control.
const correct = await fetch(`${BASE}/api/v1/recover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.78' },
body: JSON.stringify({ display_name: g.displayName, pin: g.pin }),
});
expect(correct.status).toBe(429);
});
test('the wrong-PIN streak is atomic under concurrency', async ({ guest, db }) => {
const g = await guest('BruteParallel');
const wrong = g.pin === '0000' ? '1111' : '0000';
await Promise.all(
Array.from({ length: 10 }, () =>
fetch(`${BASE}/api/v1/recover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: g.displayName, pin: wrong }),
})
)
);
// How many of the 10 get past the throttle is genuinely racy and cannot be asserted. What is
// NOT racy is that every one that DID reach the handler incremented the counter — it is a
// single `SET x = x + 1 ... RETURNING`, so none of them can be lost to the race.
expect(
await db.failedPinAttempts(g.userId),
'concurrent wrong PINs must all be counted'
).toBeGreaterThan(1);
});
});
test.describe('Adversarial — admin password brute-force', () => {
// These tests deliberately exhaust the admin-login limiter for the shared test IP. That creates a
// chicken-and-egg for the NEXT test: its truncate auto-fixture must itself call admin_login before
// it can reset the counter — so a still-full window would lock the fixture out with 429 before it
// could clear anything. Disable the toggle here via patchConfig (which authenticates with a JWT,
// NOT the rate-limited admin_login path), so the next login is clear; truncate then wipes the map.
// Runs even if the test body failed, so a failure can't poison the rest of the run.
test.afterEach(async ({ api, adminToken }) => {
await api.patchConfig(adminToken, { admin_login_rate_enabled: 'false' });
});
const tryLogin = (password: string) =>
fetch(`${BASE}/api/v1/admin/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
test('admin login is IP rate-limited: a burst of wrong passwords starts returning 429', async ({
api,
adminToken,
}) => {
// `admin_login` HAS a 5/min/IP throttle (auth/handlers.rs), gated by the master
// `rate_limits_enabled` + `admin_login_rate_enabled`. Both default TRUE in production — but the
// e2e reseed turns the master switch OFF for every test, so this defence ran in ZERO tests.
// (The previous version of this test observed all-401 under that disabled switch and wrongly
// "documented" that no rate limit exists.) Turn it on and prove it.
//
// patchConfig uses the already-minted adminToken (a JWT), so enabling the limiter does not lock
// us out of configuring it.
await api.patchConfig(adminToken, {
rate_limits_enabled: 'true',
admin_login_rate_enabled: 'true',
});
// Sequential, not parallel: the counter increments deterministically so the 429 is not itself
// subject to the counter race the PIN test covers.
const statuses: number[] = [];
for (let i = 0; i < 10; i++) {
statuses.push((await tryLogin('wrong-' + i)).status);
if (statuses[i] === 429) break;
}
// The password path actually ran (budget existed) before the limiter engaged...
expect(
statuses[0],
'first attempt should be a normal wrong-password 401, not a spurious 429'
).toBe(401);
// ...and the limiter DID engage within the window. Delete the throttle and this is never true.
expect(
statuses.some((s) => s === 429),
'a burst of wrong admin passwords from one IP must start being rate-limited (429)'
).toBe(true);
// No wrong password ever authenticated.
expect(statuses.some((s) => s === 200)).toBe(false);
});
test('the FAILURE bucket never refuses a correct admin password', async ({ api, adminToken }) => {
// This asserted the OPPOSITE — that a throttled IP is refused even with the right password —
// and that contract was deliberately removed, because at a real event it is a denial of
// service against the operator. Every guest at the venue shares one public IP behind NAT and
// `/admin/login` is a publicly linkable page, so a single tight IP bucket charged before the
// password check meant any phone in the room could keep it full and the host, on that same IP,
// could never spend a slot. The escape hatch was circular: `admin_login_rate_enabled` is only
// reachable through `PATCH /admin/config`, which needs the session being blocked.
//
// So the tight bucket is now charged ONLY on a wrong password. Brute force stays bounded (every
// guess costs a slot, per IP) while a valid credential is always honoured. A separate, generous
// per-IP ceiling bounds bcrypt CPU regardless of correctness — see ADMIN_LOGIN_CPU_CEILING.
await api.patchConfig(adminToken, {
rate_limits_enabled: 'true',
admin_login_rate_enabled: 'true',
});
// Exhaust the failure window with wrong passwords until throttled.
let throttled = false;
for (let i = 0; i < 10 && !throttled; i++) {
throttled = (await tryLogin('wrong-' + i)).status === 429;
}
expect(throttled, 'a burst of WRONG passwords from one IP must be rate-limited').toBe(true);
// The limiter is real (above) and yet the operator gets in. That combination is the whole
// property: THIS bucket keys on failure, not on the IP alone.
//
// Deliberately not claimed here: "guests cannot lock the host out". They still can — the
// separate CPU ceiling below refuses any password, correct included. This test stays under
// that ceiling on purpose so the two are not conflated.
expect(
(await tryLogin(ADMIN_PASSWORD)).status,
'a burst of wrong guesses must not cost the operator their own admin panel'
).toBe(200);
// And guessing is still throttled AFTER a successful login — a correct password must not
// refill or bypass the attacker's bucket.
expect(
(await tryLogin('wrong-again')).status,
'a successful login must not clear the failure bucket for wrong guesses'
).toBe(429);
});
test('the throttle is gated: with the limiter disabled, a burst is NOT rate-limited', async ({
api,
adminToken,
}) => {
// The mirror of the above — proves the toggle actually gates the behaviour (and documents that
// the e2e default really is "off", which is why every OTHER admin test can hammer login freely).
await api.patchConfig(adminToken, {
rate_limits_enabled: 'true',
admin_login_rate_enabled: 'false',
});
const statuses: number[] = [];
for (let i = 0; i < 8; i++) statuses.push((await tryLogin('wrong-' + i)).status);
expect(
statuses.every((s) => s === 401),
'with admin_login_rate_enabled=false no attempt should be 429'
).toBe(true);
});
});