The Playwright suite had no linter and no formatter — only tsc. Add flat-config ESLint
(typescript-eslint, type-aware) and Prettier (2-space, matching the suite's style).
Rules keep the ones that catch real TEST bugs and drop the noise:
- no-floating-promises KEPT — an un-awaited request/assertion can let a test end before it runs,
passing vacuously. It caught one: the SSE reader loop in sse-listener is now explicitly `void`.
- no-unused-vars KEPT — caught three dead bindings (an unused adminToken fixture arg, an unused
`api` arg, an unused JPEG_MAGIC import), all removed.
- no-explicit-any OFF — all test code; `any` is the honest type for an untyped res.json() body or
a page.evaluate() return.
- no-empty-pattern OFF — Playwright's dependency-free fixtures are `async ({}, use) => {}`.
Refactor: `const BASE = process.env.E2E_FRONTEND_URL ?? '...'` was redeclared verbatim in 23
files — extracted to helpers/env.ts and imported, so a port/scheme change is one edit not a sweep.
Then `prettier --write`. Verified: eslint clean, tsc clean, prettier clean, desktop suite 210
passed / 1 skipped. (One mobile spec flaked once under retries:0 — a pre-existing cross-test
reflow-timing vector from the flakiness audit, not this change: the each-key edit is stable across
16 isolated runs and a clean full mobile re-run.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
265 lines
11 KiB
TypeScript
265 lines
11 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', () => {
|
|
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);
|
|
});
|
|
});
|