Files
EventSnap/e2e/specs/07-adversarial/auth-tampering.spec.ts
MechaCat02 9b90929269 test(e2e): re-point the PIN lockout specs at the property that matters
Three specs asserted the OLD policy — that three wrong PINs lock an account —
which is exactly the behaviour the previous commit removed, because that threshold
sat below the per-(IP, name) throttle ceiling and so let any single IP lock any
guest whose display name is readable off the feed.

Rewritten to assert the distinction the fix introduces, which a status code alone
cannot show: both tiers answer 429, but only the account lock costs the VICTIM.
The new specs read the row via db.isPinLocked rather than the response, so:

- one IP hammering /recover is throttled and the account stays UNLOCKED;
- a distributed guesser (counter preloaded via db.setFailedPinAttempts, since no
  single source can reach the threshold any more) still trips the lock, and it
  holds even against the correct PIN;
- concurrent wrong PINs are all counted — the atomicity property the old parallel
  test was really about, now asserted on the counter instead of inferred from a
  429 that the throttle could equally have produced.

The UI spec asserts the user-visible half: after four wrong PINs Dave can still
get into his own account. It also now types the PIN digit by digit rather than
filling and clicking, because the 4th digit auto-submits (pin-auto-submit.spec.ts)
and doing both raced the button's disabled state.

The adversarial spec enables rate_limits_enabled for its own run — it is off by
default in this environment, so without that the throttle tier would silently not
be exercised — and restores it in afterEach so it cannot leak into other specs
sharing the stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:58:17 +02:00

304 lines
12 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('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);
});
});