/** * 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(() => ({})); expect((json.message ?? '').toLowerCase()).toMatch(/entspricht nicht|deklarierten/); }); 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 = ``; 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.'); } }); });