/**
* 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