Files
EventSnap/e2e/specs/07-adversarial/xss-injection.spec.ts
MechaCat02 e42d8a92a1 feat(e2e): Playwright suite — 134 tests across 9 spec areas + UA matrix
Adds an end-to-end Playwright test suite under e2e/ that spins up an
isolated docker-compose stack (Postgres :55432, Caddy :3101, backend with
EVENTSNAP_TEST_MODE=1, SvelteKit adapter-node frontend) and exercises the
SvelteKit app against the real Rust backend.

Phase 1 — happy paths covering every documented USER_JOURNEYS.md flow:
  01-auth/      join, recover, admin login, leave event, PIN lockout
  02-upload/    gallery picker (API path), rate-limit + admin toggle
  03-feed/      like/comment SSE, filters, SSE reconnect on visibility
  04-host/      event lock API, ban/unban, promote
  05-admin/     config validation, foundational authz guards, stats
  06-export/    /export status + download stub
  __smoke/      cross-UA happy-path (runs on every UA project)

Phase 2 — adversarial + browser chaos:
  07-adversarial/  XSS payloads (6 × display name path), SQLi shapes,
                   length / encoding / RTL override / NUL byte;
                   file-upload boundaries (ELF body claimed as JPEG,
                   oversize vs max_image_size_mb, zero-byte, NUL
                   filename, path-traversal, SVG-with-script);
                   JWT alg:none, signature/payload tamper, expired
                   session, PIN brute-force (serial + parallel),
                   admin password brute-force; deep authz (cross-user
                   delete, banned user across like/comment/feed-read,
                   host→admin escalation); small-scale DDoS (20× /join,
                   10MB comment body, 10 concurrent SSE).
  08-browser-chaos/ localStorage / sessionStorage / cookie purge,
                    IndexedDB drop mid-session, offline → reconnect,
                    slow-3G, 503 flakes, 429 with no retry storm,
                    multi-tab same/different user, no-JS, hostile CSS,
                    clock skew ±1h / -2d, localStorage quota exhausted.

Phase 3 — mobile gestures (runs only on chromium-mobile / Pixel 7):
  09-mobile/    touch-target ≥44px audit, env(safe-area-inset-bottom)
                structural check, long-press (FeedListCard → ContextSheet,
                quick-tap negation, click-suppression), double-tap
                (feed card like + lightbox heart-burst, via synthetic
                pointer events to bypass the first-tap-fires-click trap),
                viewport reflow (portrait/landscape/narrow/phablet),
                plus fixme stubs documenting planned gestures (swipe
                lightbox L/R, swipe-down dismiss, pull-to-refresh,
                long-press-comment).

Cross-UA matrix (chromium-engine projects run @smoke only):
  chromium-pixel7, chromium-galaxy-s22, samsung-internet (Samsung UA
  emulation on Galaxy viewport), edge-android, plus webkit-iphone,
  chrome-ios, firefox-android, firefox-desktop — the latter four need
  libavif16 on the host (Playwright dep) but the configs are in place.

Infrastructure:
  - fixtures/test.ts central test.extend (api, db, adminToken, guest,
    host, signIn). Per-test DB truncate via the dev-only POST
    /admin/__truncate route, gated by EVENTSNAP_TEST_MODE=1.
  - helpers/sse-listener.ts, helpers/upload-client.ts (Node-side
    multipart for adversarial file-upload tests + JPEG/PNG/ELF magic
    constants), helpers/touch.ts (longPress / doubleTap / swipe /
    inlineStyle / computedStyle).
  - 10 page objects covering every route + UploadSheet/Lightbox.
  - global-setup waits for /health, logs in admin, disables every
    rate-limit and quota toggle.
  - .github/workflows/e2e.yml: PR check runs chromium-desktop + the
    smoke matrix in parallel, uploads playwright-report/ and traces on
    failure.

Findings the suite surfaces as live `[finding]` warnings (not silenced):
  1. /admin/login has no rate-limit or lockout (bcrypt cost only).
  2. PIN-attempt counter races under parallel /recover requests.
  3. Zero-byte uploads pass /api/v1/upload.
  4. SVG-with-script can pass the magic-byte check (consider CSP +
     X-Content-Type-Options on /media/*).

Stack-internal docs live in e2e/README.md (UA tier table, Samsung
Internet escalation tiers A/B/C, debugging tips, roadmap).

Final tally: 134 passed / 0 failed / 9 skipped (test.fixme stubs for
not-yet-shipped gestures and one UI-upload-flow investigation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:37:11 +02:00

151 lines
6.0 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.
/**
* 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';
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(() => {});
});
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 — 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);
});
});