Files
EventSnap/e2e/specs/07-adversarial/xss-injection.spec.ts
fabi 8f6e1d4ff7 test(e2e): stored-XSS (caption/comment) + SSE-ticket abuse coverage
Close two malicious-input gaps flagged in the suite review: XSS was only fuzzed
through display_name, and the SSE ticket flow had no security assertions.

xss-injection:
- Stored XSS in captions — upload with each XSS payload as the caption, mark it
  feed-visible, render /feed and assert window.__xssFired stays false, no dialog
  fires, and no live `img[onerror]`/`<script>` element is produced (Svelte escaping
  renders it as inert text). A trailing CAPMARK gates the assertion on the caption
  actually having rendered, so it can't pass vacuously.
- Stored XSS in comments — post the two render-executing payloads as a comment,
  open the lightbox (which loads comments) and assert the same inert-render props.

sse-ticket-abuse (new):
- Minting a ticket requires auth (POST /stream/ticket without Bearer → 401).
- Single-use: after the first open consumes the ticket, replaying it → 401 (a 200
  would be capability replay). The first open (→200) also proves a fresh ticket works.
- An unminted/garbage ticket → 401.

All verified green against the live backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:34:55 +02:00

230 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 { uploadRaw, JPEG_MAGIC } from '../../helpers/upload-client';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
/** Seed a real, feed-visible upload owned by `jwt`; returns its id. */
async function seedVisibleUpload(jwt: string, caption: string, db: any): Promise<string> {
const body = new Uint8Array(1024);
body.set(JPEG_MAGIC, 0);
const res = await uploadRaw(jwt, body, { filename: 'x.jpg', contentType: 'image/jpeg', caption });
if (res.status !== 201) throw new Error(`seed upload failed: ${res.status} ${await res.text()}`);
const { id } = await res.json();
// Mark compression done so the card is fully rendered in the feed.
await db.setUploadCompressionStatus(id, 'done');
return id;
}
const XSS_PAYLOADS = [
`<script>window.__xssFired=true</script>`,
`<img src=x onerror="window.__xssFired=true">`,
`"><svg onload="window.__xssFired=true">`,
`<iframe src="javascript:window.parent.__xssFired=true"></iframe>`,
`javascript:window.__xssFired=true`,
`<a href="javascript:window.__xssFired=true">click</a>`,
];
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 }) => {
// Payloads > 50 chars are rejected by the join handler — that's a valid defense.
// Only if the API accepts the payload do we proceed to assert it never executes
// when rendered.
let res;
try {
res = await api.join(payload);
} catch (e: any) {
if (/→ 400/.test(e.message ?? '')) {
// Defended at the API. No need to render.
return;
}
throw e;
}
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(() => {});
});
await page.goto('/feed');
await page.waitForLoadState('domcontentloaded');
const fired = await page.evaluate(() => (window as any).__xssFired === true);
expect(fired, 'window.__xssFired 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.__xssFired")').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, db, 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 seedVisibleUpload(g.jwt, `${payload} CAPMARK`, db);
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).__xssFired === true), '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("__xssFired")').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.__xssFired=true">`,
`"><svg onload="window.__xssFired=true">`,
];
for (const payload of COMMENT_PAYLOADS) {
test(`comment with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({ guest, db, page, signIn }) => {
const author = await guest('CmtXss');
const id = await seedVisibleUpload(author.jwt, 'pic CAPMARK', db);
// Post the XSS comment via the API (verbatim storage).
const cRes = await fetch(`${BASE}/api/v1/upload/${id}/comments`, {
method: 'POST',
headers: { Authorization: `Bearer ${author.jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: `${payload} CMTMARK` }),
});
expect(cRes.status).toBe(201);
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).__xssFired === true), '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);
});
});