Files
EventSnap/e2e/specs/07-adversarial/file-upload-attacks.spec.ts
fabi a3c0082c4b test(e2e): update suite for shipped features + gate Chromium-only perms
The running test container had been built from an older tree; rebuilding it to
pick up current app code surfaced several tests that predated shipped
security/UX features:

- file-upload-attacks / rate-limit: assert the magic-byte rejection wording and
  upload a real decodable JPEG (a zero-buffer is now rejected at the boundary).
- ddos: open SSE via the single-use /stream/ticket flow, not the dead ?token=.
- recover-page / join: the PIN field auto-submits on the 4th digit, so don't
  race an explicit submit click against the ensuing navigation.
- gestures-doubletap / sheet-escape: target the lightbox by aria-labelledby and
  anchor the radio accessible-name match at the start (gated dialog semantics).

playwright.config: camera/mic/clipboard are Chromium-only permissions (they threw
"Unknown permission: camera" on firefox/webkit and failed the test at context
creation); grant them per-Chromium-project. firefox-android drops the
unsupported isMobile flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:12:05 +02:00

141 lines
6.7 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 — file upload boundary tests. Exercises every input
* validation rule baked into [backend/src/handlers/upload.rs]:
*
* 1. Magic-byte detection rejects spoofed MIME categories
* (declared image/jpeg, actual application/octet-stream of e.g. an ELF binary).
* 2. Size limits read from the `config` table reject oversize files.
* 3. Filenames are not used as filesystem paths (path traversal ignored).
* 4. Zero-byte and missing-file cases fail safely.
* 5. `content_type: application/...` bypasses category check but still goes through size validation.
*/
import { test, expect } from '../../fixtures/test';
import { uploadRaw, ELF_MAGIC, JPEG_MAGIC } from '../../helpers/upload-client';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
test.describe('Adversarial — file upload', () => {
test('claimed image/jpeg with ELF body is rejected by magic-byte check', async ({ guest }) => {
const g = await guest('MimeSpoof');
const body = new Uint8Array(1024);
body.set(ELF_MAGIC, 0);
const res = await uploadRaw(g.jwt, body, { filename: 'evil.jpg', contentType: 'image/jpeg' });
expect(res.status).toBe(400);
const json: any = await res.json().catch(() => ({}));
// The handler derives the type from magic bytes and ignores the declared MIME
// entirely, so the rejection reads "Dateityp wird nicht unterstützt: …" (on the
// allowlist miss) or "… nicht erkannt …" (when infer returns None).
expect((json.message ?? '').toLowerCase()).toMatch(/nicht unterstützt|nicht erkannt/);
});
test('claimed image/jpeg with video bytes is rejected (cross-category)', async ({ guest }) => {
const g = await guest('CrossCat');
// Minimal MP4 ftyp header — infer detects as video/mp4.
const body = new Uint8Array(64);
const ftyp = new TextEncoder().encode('ftypisom');
body.set([0x00, 0x00, 0x00, 0x18], 0);
body.set(ftyp, 4);
const res = await uploadRaw(g.jwt, body, { filename: 'evil.jpg', contentType: 'image/jpeg' });
// Backend either rejects with 400 (cross-category) or accepts as video — both are documented behaviors.
expect([400, 201]).toContain(res.status);
});
test('oversize image (declared > max_image_size_mb) is rejected with 400', async ({ api, adminToken, guest }) => {
await api.patchConfig(adminToken, { max_image_size_mb: '1' }); // 1 MB cap for the test
try {
const g = await guest('Oversize');
const body = new Uint8Array(2 * 1024 * 1024); // 2 MB
body.set(JPEG_MAGIC, 0);
const res = await uploadRaw(g.jwt, body, { filename: 'big.jpg', contentType: 'image/jpeg' });
expect(res.status).toBe(400);
const json: any = await res.json().catch(() => ({}));
expect((json.message ?? '').toLowerCase()).toMatch(/zu groß|too large/i);
} finally {
await api.patchConfig(adminToken, { max_image_size_mb: '20' });
}
});
test('zero-byte file behavior (documented finding if accepted)', async ({ guest }) => {
const g = await guest('ZeroByte');
const res = await uploadRaw(g.jwt, new Uint8Array(0), { filename: 'empty.jpg', contentType: 'image/jpeg' });
// Current backend accepts zero-byte JPEGs (201) because the magic-byte check
// returns None for empty input and short-circuits validation. Documented as
// a finding — the compression worker should reject them downstream, but
// a 400 at the upload boundary would be cleaner.
expect([201, 400]).toContain(res.status);
if (res.status === 201) {
console.warn('[finding] zero-byte uploads pass /api/v1/upload — consider rejecting empty bodies upfront.');
}
});
test('multipart with no file field at all is rejected', async ({ guest }) => {
const g = await guest('NoFile');
const form = new FormData();
form.append('caption', 'no file here');
const res = await fetch(`${BASE}/api/v1/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${g.jwt}` },
body: form,
});
expect(res.status).toBe(400);
});
test('filename with path traversal is ignored (server uses upload_id for storage)', async ({ guest }) => {
const g = await guest('PathTrav');
const body = new Uint8Array(1024);
body.set(JPEG_MAGIC, 0);
const res = await uploadRaw(g.jwt, body, {
filename: '../../../../etc/passwd',
contentType: 'image/jpeg',
});
expect([201, 400]).toContain(res.status);
// Critical assertion: the server must not have created or read /etc/passwd. We can't directly
// probe the container, so we settle for the upload-response-doesn't-leak path check below.
if (res.status === 201) {
const json: any = await res.json();
expect(JSON.stringify(json)).not.toContain('/etc/');
expect(JSON.stringify(json)).not.toContain('..');
}
});
test('filename with embedded NUL byte does not crash the server', async ({ guest }) => {
const g = await guest('NulFile');
const body = new Uint8Array(1024);
body.set(JPEG_MAGIC, 0);
const res = await uploadRaw(g.jwt, body, {
filename: 'evil.jpg',
contentType: 'image/jpeg',
});
// Either accepted (NUL stripped) or rejected — server stays up.
expect([201, 400]).toContain(res.status);
});
test('content_type=application/... bypasses category check (documented behavior)', async ({ guest }) => {
const g = await guest('AppOctet');
const body = new Uint8Array(1024);
body.set(JPEG_MAGIC, 0); // valid JPEG bytes
const res = await uploadRaw(g.jwt, body, {
filename: 'thing.bin',
contentType: 'application/octet-stream',
});
// The upload.rs check returns Ok if declared category == "application".
// We expect 201 — the size check still applies under max_image_mb default.
expect(res.status).toBe(201);
});
test('SVG with embedded script — current behavior documented', async ({ guest }) => {
const g = await guest('SvgScript');
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`;
const body = new TextEncoder().encode(svg);
const res = await uploadRaw(g.jwt, body, { filename: 'evil.svg', contentType: 'image/svg+xml' });
// `infer` may not detect SVG at all → no category mismatch → the upload could succeed.
// This test documents current behavior so a future tightening (Content-Disposition: attachment,
// CSP on /media/*) is intentional rather than accidental.
// Acceptable outcomes: 201 (accepted), 400 (rejected by detection).
expect([201, 400]).toContain(res.status);
if (res.status === 201) {
console.warn('[finding] SVG-with-script accepted by upload handler — consider serving /media with X-Content-Type-Options: nosniff and CSP.');
}
});
});