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>
151 lines
6.0 KiB
TypeScript
151 lines
6.0 KiB
TypeScript
/**
|
||
* 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: 'Mallory |