Files
EventSnap/e2e/specs/07-adversarial/file-upload-attacks.spec.ts
fabi 20125fb713 test(e2e): pin security assertions + real cross-user IDOR coverage
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>
2026-07-01 07:27:22 +02:00

140 lines
6.8 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 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 <script> is rejected (stored-XSS defense)', 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` returns None for text-based payloads (SVG/HTML/JS), so the handler rejects
// with 400 — this is the primary defense against serving an active <script> from the
// media origin. Pinned (not [201,400]): a 201 here is a stored-XSS regression, the
// single most dangerous outcome this suite can catch.
expect(res.status).toBe(400);
const json: any = await res.json().catch(() => ({}));
expect((json.message ?? '').toLowerCase()).toMatch(/nicht erkannt|nicht unterstützt/);
});
});