Files
EventSnap/e2e/specs/07-adversarial/xss-injection.spec.ts
fabi 02971f3186 test(e2e): de-vacuum security tests; add quota, authz-sweep, keepsake-regen coverage
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>
2026-07-15 07:26:40 +02:00

225 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Phase 2 adversarial — string-based input attacks. Verifies that user-
* supplied text is treated as data, not code, everywhere it surfaces in
* the UI.
*
* Strategy: feed each attack payload through the public API at the
* earliest entry point that accepts free-form text (display name, caption,
* comment), then render the same data back through the UI and assert that:
* 1. The server stores the input verbatim (no over-eager rejection that
* would impede legitimate names like "O'Brien" or "T-Rex").
* 2. The DOM never produces an executable script element / a dialog event
* / a navigation to a `javascript:` URL.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload, seedComment } from '../../helpers/seed';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
/**
* Every payload sets `window.__x = 1` if it executes. The marker is deliberately terse:
* the join handler caps display names at 50 chars, and a payload that trips that cap is
* rejected at the API — which means it is NEVER stored and NEVER rendered, so the test
* that "nothing executed" proves nothing at all. Each payload below is < 50 chars, so it
* survives the join and actually reaches the render sink under test.
*/
const XSS_PAYLOADS = [
`<script>window.__x=1</script>`, // 29
`<img src=x onerror="window.__x=1">`, // 34
`"><svg onload="window.__x=1">`, // 29
`<iframe src="javascript:parent.__x=1"></iframe>`, // 47
`javascript:window.__x=1`, // 23
`<a href="javascript:window.__x=1">c</a>`, // 39
];
// Guard the invariant the payloads depend on: if the display-name cap ever changes, or a
// payload is edited past it, we want a loud failure here rather than six silent no-ops.
const NAME_MAX = 50;
for (const p of XSS_PAYLOADS) {
if (p.length > NAME_MAX) throw new Error(`XSS payload exceeds the ${NAME_MAX}-char display-name cap and would never be stored: ${p}`);
}
const SQLI_PAYLOADS = [
`'; DROP TABLE "user"; --`,
`' OR 1=1 --`,
`Robert'); DROP TABLE upload; --`,
`\\'; SELECT pg_sleep(5); --`,
];
test.describe('Adversarial — input injection (display name)', () => {
for (const payload of XSS_PAYLOADS) {
test(`name with XSS payload ${JSON.stringify(payload).slice(0, 40)} never executes`, async ({ api, page }) => {
// No try/catch escape hatch: every payload is short enough to be accepted, so a
// rejection here is a real failure (the payload would never be rendered, and the
// "nothing executed" assertions below would be vacuous).
const res = await api.join(payload);
expect(res.jwt).toBeTruthy();
// Render the name in the account page by signing in.
await page.goto('/');
await page.evaluate((j) => localStorage.setItem('eventsnap_jwt', j), res.jwt);
await page.evaluate((u) => localStorage.setItem('eventsnap_user_id', u), res.user_id);
await page.evaluate((n) => localStorage.setItem('eventsnap_display_name', n), payload);
await page.evaluate((p) => localStorage.setItem('eventsnap_pin', p), res.pin);
// Listen for dialogs (alert/confirm/prompt) — any one means the payload escaped.
const dialogs: string[] = [];
page.on('dialog', (d) => {
dialogs.push(d.message());
d.dismiss().catch(() => {});
});
// /account is where the viewer's own display name is rendered (the sink). /feed
// shows uploader names, not the viewer's — navigating there rendered nothing, so
// the non-firing check passed vacuously. Go where the payload actually lands.
await page.goto('/account');
await page.waitForLoadState('domcontentloaded');
// Render guard: confirm the payload actually reached the DOM as escaped text,
// so a "nothing fired" pass can't be because the name was never rendered.
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({ timeout: 10_000 });
const fired = await page.evaluate(() => (window as any).__x === 1);
expect(fired, 'window.__x should never be set').toBe(false);
expect(dialogs, 'no dialogs should appear').toHaveLength(0);
// Inline script tag in the displayed name should be rendered as text, not parsed.
const scriptCount = await page.locator('script:has-text("window.__x")').count();
expect(scriptCount, 'no executable script tags rendered from name').toBe(0);
});
}
});
test.describe('Adversarial — stored XSS (caption)', () => {
for (const payload of XSS_PAYLOADS) {
test(`caption with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({ guest, page, signIn }) => {
const g = await guest('CapXss');
// A trailing marker lets us wait until the caption has actually rendered before
// asserting nothing fired — otherwise a caption that never rendered would pass vacuously.
const id = await seedUpload(g.jwt, { caption: `${payload} CAPMARK` });
expect(id).toMatch(/^[0-9a-f-]{36}$/);
const dialogs: string[] = [];
page.on('dialog', (d) => { dialogs.push(d.message()); d.dismiss().catch(() => {}); });
await signIn(page, g);
// Wait for the caption text to land in the DOM (escaped, as literal text).
await expect(page.getByText('CAPMARK', { exact: false }).first()).toBeVisible({ timeout: 10_000 });
expect(await page.evaluate(() => (window as any).__x === 1), 'caption XSS must not fire').toBe(false);
expect(dialogs, 'no dialogs from a caption').toHaveLength(0);
// The payload must be inert text, not a live element / script.
expect(await page.locator('img[onerror]').count(), 'no live onerror img from caption').toBe(0);
expect(await page.locator('script:has-text("window.__x")').count(), 'no executable script from caption').toBe(0);
});
}
});
test.describe('Adversarial — stored XSS (comment)', () => {
// The two payloads that actually execute on render (script injection via innerHTML
// does not) — enough to prove the comment body is escaped without a slow 6× lightbox loop.
const COMMENT_PAYLOADS = [
`<img src=x onerror="window.__x=1">`,
`"><svg onload="window.__x=1">`,
];
for (const payload of COMMENT_PAYLOADS) {
test(`comment with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({ guest, page, signIn }) => {
const author = await guest('CmtXss');
const id = await seedUpload(author.jwt, { caption: 'pic CAPMARK' });
// Post the XSS comment via the API (verbatim storage).
await seedComment(author.jwt, id, `${payload} CMTMARK`);
const dialogs: string[] = [];
page.on('dialog', (d) => { dialogs.push(d.message()); d.dismiss().catch(() => {}); });
await signIn(page, author);
// Open the lightbox (which loads + renders comments).
const imageButton = page.locator('article').filter({ hasText: 'CAPMARK' }).first()
.getByRole('button', { name: 'Bild vergrößern' });
await expect(imageButton).toBeVisible({ timeout: 10_000 });
await imageButton.click();
const lightbox = page.locator('[role="dialog"][aria-labelledby="lightbox-title"]');
await expect(lightbox).toBeVisible();
// Wait until the comment (marker) has rendered.
await expect(lightbox.getByText('CMTMARK', { exact: false })).toBeVisible({ timeout: 10_000 });
expect(await page.evaluate(() => (window as any).__x === 1), 'comment XSS must not fire').toBe(false);
expect(dialogs, 'no dialogs from a comment').toHaveLength(0);
expect(await page.locator('img[onerror]').count(), 'no live onerror img from comment').toBe(0);
});
}
});
test.describe('Adversarial — input injection (SQL-injection patterns)', () => {
for (const payload of SQLI_PAYLOADS) {
test(`SQL-shaped name ${JSON.stringify(payload).slice(0, 40)} round-trips without breaking the DB`, async ({ api, adminToken }) => {
const res = await api.join(payload);
expect(res.jwt).toBeTruthy();
// Sanity: the DB is still queryable afterwards.
const cfg = await api.getConfig(adminToken);
expect(Object.keys(cfg).length).toBeGreaterThan(0);
});
}
});
test.describe('Adversarial — input length & encoding', () => {
test('display name longer than 50 chars is rejected with 400', async () => {
const huge = 'A'.repeat(10_000);
const res = await fetch(`${BASE}/api/v1/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: huge }),
});
expect(res.status).toBe(400);
});
test('empty / whitespace-only display name is rejected', async () => {
for (const name of ['', ' ', '\t\n ']) {
const res = await fetch(`${BASE}/api/v1/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: name }),
});
expect(res.status).toBe(400);
}
});
test('display name with NUL byte is handled (rejected or stored — never crashes)', async () => {
const res = await fetch(`${BASE}/api/v1/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: 'MalloryBob' }),
});
// Postgres rejects NUL in text fields with 0xc0 0x80 encoding; either 400 or 201 is acceptable.
expect([400, 201, 409]).toContain(res.status);
});
test('Unicode RTL override character in name does not corrupt rendering', async ({ api, page }) => {
const rtlName = `AliceeciVlA`; // U+202E RIGHT-TO-LEFT OVERRIDE
const r = await api.join(rtlName);
expect(r.user_id).toMatch(/^[0-9a-f-]{36}$/);
await page.goto('/');
await page.evaluate((j) => localStorage.setItem('eventsnap_jwt', j), r.jwt);
await page.evaluate((u) => localStorage.setItem('eventsnap_user_id', u), r.user_id);
await page.goto('/feed');
// The page must render without throwing.
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible();
});
test('caption longer than 2000 chars is rejected', async ({ guest }) => {
const g = await guest('CapLen');
const form = new FormData();
form.append('file', new Blob([new Uint8Array(640)], { type: 'image/jpeg' }), 'x.jpg');
form.append('caption', 'A'.repeat(2001));
const res = await fetch(`${BASE}/api/v1/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${g.jwt}` },
body: form,
});
expect(res.status).toBe(400);
});
});