Turn "accept-both-outcomes" documentation tests — which pass whether the app is secure or vulnerable — into assertions that pin the secure behavior, and replace fake-UUID authz tests that 404'd before ever reaching the ownership guard with real cross-user resources. file-upload-attacks: - SVG-with-<script> → pin 400 (infer returns None for text → stored-XSS defense); was [201,400], which accepted the vulnerable outcome. - zero-byte → pin 400; path-traversal → pin 201 with UUID-derived storage and a no-path-echo check (both were [201,400]). - Fix the application/octet-stream rationale (no "application bypass" exists — the handler ignores the declared type and keys off magic bytes). authorization-deep (IDOR): - B deleting A's comment: seed a real upload+comment as A, assert 403, assert the comment survives, and assert the owner (A) still gets 204 — proving the 403 is about identity, not a broken route. (Was a DELETE on the all-zeros UUID → 404 before the user_id guard, so authorization was never exercised.) - Add B-deletes-A's-upload (403, countUploads unchanged) and B-edits-A's-caption (403, caption intact) — the find_by_id_and_event + ownership guards. export: pin the ZIP-download 404 (was [404,200] — a 200 is a data-exposure regression) and split into the two real branches: not-yet-ready, and ready-but-file-missing (new db.setExportZipReady helper). All 21 assertions verified green against the live secure backend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
140 lines
6.8 KiB
TypeScript
140 lines
6.8 KiB
TypeScript
/**
|
||
* 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 |