/** * 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 is rejected at the upload boundary', async ({ guest }) => { const g = await guest('ZeroByte'); const res = await uploadRaw(g.jwt, new Uint8Array(0), { filename: 'empty.jpg', contentType: 'image/jpeg' }); // `infer::get(&[])` returns None → the handler rejects with 400 ("Dateityp nicht // erkannt…"). Pinned (not [201,400]): a 201 would mean an empty/garbage file slipped // past magic-byte validation, which is exactly the regression this test guards. expect(res.status).toBe(400); const json: any = await res.json().catch(() => ({})); expect((json.message ?? '').toLowerCase()).toMatch(/nicht erkannt|nicht unterstützt/); }); 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', }); // Pinned to 201: the payload is a valid JPEG and the handler ignores the client // filename entirely (storage path is derived from the upload UUID), so traversal // in the name must neither reject the upload nor influence storage. expect(res.status).toBe(201); // The response must not echo the attacker-supplied path back (no `/etc/`, no `..`). 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('declared content_type is ignored; acceptance follows the magic bytes', 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', }); // There is no "application category bypass": upload.rs ignores the declared // Content-Type entirely and keys off `infer`'s magic-byte detection. The body is a // real JPEG, so it's accepted (201) despite the octet-stream label. (The mirror of // this is the ELF-as-image/jpeg test above, which is rejected for the same reason.) expect(res.status).toBe(201); }); test('SVG with embedded `; const body = new TextEncoder().encode(svg); const res = await uploadRaw(g.jwt, body, { filename: 'evil.svg', contentType: 'image/svg+xml' }); // `infer` returns None for text-based payloads (SVG/HTML/JS), so the handler rejects // with 400 — this is the primary defense against serving an active