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>
163 lines
7.3 KiB
TypeScript
163 lines
7.3 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';
|
||
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 |