Files
EventSnap/e2e/specs/07-adversarial/xss-injection.spec.ts
fabi b1e2e66305 test(e2e): address self-review follow-ups (dedup, XSS render guard, SSE hardening)
Follow-ups from the code review of the test-quality batches:

- Consolidate duplicated helpers into e2e/helpers/: seed.ts (seedUpload,
  seedComment, listComments, findFeedRow) and sse.ts (mintSseTicket, openStream,
  trackStreamOpens). Refactor authorization-deep, xss-injection, like-comment,
  sse-ticket-abuse, ddos, sse-realtime, multi-tab, and SseListener to use them —
  the upload/comment/ticket-flow contracts now live in one place each instead of
  being re-inlined across 3–7 specs.
- xss-injection display-name loop: it navigated to /feed (which renders uploader
  names, not the viewer's) so "nothing fired" passed vacuously — the payload was
  never rendered. Now navigate to /account (the actual sink) and add a render
  guard asserting the payload reached the DOM as escaped text before checking
  __xssFired.
- sse-realtime reconnect: snapshot the stream-open count AFTER backgrounding, so
  the "new connection" assertion is attributable to the foreground event and can't
  be satisfied by a spurious native/error reconnect before the toggle.
- recover-page: correct the comment (auto-submit is the onPinInput handler, not an
  $effect).

44 affected specs verified green.

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

220 lines
9.8 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';
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(() => {});
});
// /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).__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, 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).__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, 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).__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);
});
});