/** * 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'; const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; /** 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', () => { test('sequential wrong-PIN attempts lock the account after 3 attempts', async ({ guest }) => { const g = await guest('Brute'); const wrong = g.pin === '0000' ? '1111' : '0000'; // Do them serially so the failed_pin_attempts counter increments // monotonically. Parallel attempts race and may never accumulate to 3 in // the current handler implementation — that's a separate finding. const statuses: number[] = []; for (let i = 0; i < 4; 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); } // First three are 401, fourth (or later) is 429. expect(statuses.filter((s) => s === 200)).toHaveLength(0); expect(statuses.some((s) => s === 429)).toBe(true); // Now even the correct PIN fails until lockout expires. const correct = await fetch(`${BASE}/api/v1/recover`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: g.displayName, pin: g.pin }), }); expect(correct.status).toBe(429); }); test('parallel wrong-PIN attempts still lock the account (counter is not lost to the race)', async ({ guest }) => { const g = await guest('BruteParallel'); const wrong = g.pin === '0000' ? '1111' : '0000'; const attempts = 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 }), }) ) ); const statuses = attempts.map((r) => r.status); expect(statuses.filter((s) => s === 200), 'a wrong PIN must never authenticate').toHaveLength(0); // The in-flight requests all read `pin_locked_until` before any of them wrote it, so // *which* of the 10 come back 429 is genuinely racy and can't be asserted. What is NOT // racy — and is the property this test exists to guard — is the state left behind: // `failed_pin_attempts` is incremented with an atomic `SET x = x + 1 ... RETURNING`, so // 10 wrong PINs must push it past the 3-strike threshold and leave the account locked. // // We prove that with a follow-up request using the CORRECT pin: it must be refused with // 429 (locked), not 200. Delete the lockout counter and this line goes 200 → red. const correct = await fetch(`${BASE}/api/v1/recover`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: g.displayName, pin: g.pin }), }); expect(correct.status, 'after 10 wrong PINs the account must be locked, even for the right PIN').toBe(429); }); }); 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('once throttled, even the CORRECT admin password is refused (it is an IP limit, not a password check)', async ({ api, adminToken, }) => { // This is the assertion that makes the test non-vacuous: it isolates the RATE LIMIT from the // password logic. If the throttle were removed, the correct password would return 200 here. await api.patchConfig(adminToken, { rate_limits_enabled: 'true', admin_login_rate_enabled: 'true', }); // Exhaust the 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, 'the IP should be throttled after a burst').toBe(true); // The right password, while throttled, must STILL be refused — the limiter is checked before // the bcrypt verify, so a valid credential does not buy a way around a brute-force lockout. expect((await tryLogin(ADMIN_PASSWORD)).status, 'a throttled IP is refused even with the correct password').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); }); });