An audit found tests that pass on broken code. The dominant pattern: fire a security
assertion at the all-zeros UUID and accept [403, 404] — the 404 comes from the resource
LOOKUP, not the guard, so the guard can be deleted and the test still passes. Repaired to
use real resources and demand exactly 403:
- banned-user cannot like / comment (the only coverage of those ban invariants)
- host cannot promote a real guest (or self) to admin — asserts nobody's role changed
- IDOR comment-delete already used a real resource; kept
Also inverted recovery.spec's "unknown name → nicht gefunden" test: that asserted the exact
account-enumeration oracle the F4 fix removed, so restoring the vuln would have made it
pass. Now: an unknown name must be byte-identical to a wrong PIN.
New coverage for paths that ran in production but in zero tests:
- quota.spec.ts: storage quota enforcement (413 over-limit, atomic increment under two
uploads held mid-body so both carry a stale total=0 — the real race; a naive Promise.all
version was itself vacuous and is documented as such). Proven to fail without the guard.
- authz-sweep.spec.ts: table-driven guest→403 / host→403 over ALL 19 privileged routes +
anonymous + __truncate. No live hole found; the whole surface is now locked.
- ban / unban / host-comment-delete AFTER release regenerate the keepsake (data-loss
paths that were dead under test); comment-delete mid-build doesn't strand the ZIP.
Lower-severity de-vacuuming: 10 MB comment test hit Caddy's 502 before the real 500-char
cap (now seeds a real upload, 501→400 / 500→201, mutation-verified); XSS name payloads
shortened under the 50-char cap so they actually store+render; ui-rendering XSS test now
proves the payload rendered before asserting no <b>; export page-object locators fixed to
the real "Download" label with a positive empty-state anchor; avatar palette spread test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
164 lines
6.9 KiB
TypeScript
164 lines
6.9 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';
|
|
|
|
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', () => {
|
|
test('repeated wrong passwords do NOT lock the admin (documented finding)', async () => {
|
|
// The admin login handler does not currently implement lockout. This test
|
|
// documents the behavior so any future change is intentional.
|
|
const attempts = await Promise.all(
|
|
Array.from({ length: 10 }, () =>
|
|
fetch(`${BASE}/api/v1/admin/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password: 'wrong-' + Math.random() }),
|
|
})
|
|
)
|
|
);
|
|
const statuses = attempts.map((r) => r.status);
|
|
expect(statuses.every((s) => s === 401)).toBe(true);
|
|
console.warn('[finding] /admin/login has no rate-limit or lockout — bcrypt cost is the only defense.');
|
|
});
|
|
});
|