Files
EventSnap/e2e/specs/07-adversarial/file-upload-attacks.spec.ts
fabi bbdfae09a0 chore(e2e): add ESLint + Prettier; fix real findings; dedupe BASE
The Playwright suite had no linter and no formatter — only tsc. Add flat-config ESLint
(typescript-eslint, type-aware) and Prettier (2-space, matching the suite's style).

Rules keep the ones that catch real TEST bugs and drop the noise:
  - no-floating-promises KEPT — an un-awaited request/assertion can let a test end before it runs,
    passing vacuously. It caught one: the SSE reader loop in sse-listener is now explicitly `void`.
  - no-unused-vars KEPT — caught three dead bindings (an unused adminToken fixture arg, an unused
    `api` arg, an unused JPEG_MAGIC import), all removed.
  - no-explicit-any OFF — all test code; `any` is the honest type for an untyped res.json() body or
    a page.evaluate() return.
  - no-empty-pattern OFF — Playwright's dependency-free fixtures are `async ({}, use) => {}`.

Refactor: `const BASE = process.env.E2E_FRONTEND_URL ?? '...'` was redeclared verbatim in 23
files — extracted to helpers/env.ts and imported, so a port/scheme change is one edit not a sweep.

Then `prettier --write`. Verified: eslint clean, tsc clean, prettier clean, desktop suite 210
passed / 1 skipped. (One mobile spec flaked once under retries:0 — a pre-existing cross-test
reflow-timing vector from the flakiness audit, not this change: the each-key edit is stable across
16 isolated runs and a clean full mobile re-run.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:45:59 +02:00

163 lines
7.3 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';
import { BASE } from '../../helpers/env';
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 stored as video/mp4 (magic bytes win)', 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' });
// Pinned (not [201,400]): upload.rs ignores the declared Content-Type and keys off
// `infer`'s magic bytes. video/mp4 is on the allowlist, so the upload is accepted —
// but it must be accepted AS A VIDEO. The declared `image/jpeg` must never survive
// into the stored row, or every downstream size/serve decision would be made on an
// attacker-supplied label.
expect(res.status).toBe(201);
const json: any = await res.json();
expect(json.mime_type, 'stored MIME must come from the bytes, not the declared type').toBe(
'video/mp4'
);
});
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/);
});
});