The running test container had been built from an older tree; rebuilding it to pick up current app code surfaced several tests that predated shipped security/UX features: - file-upload-attacks / rate-limit: assert the magic-byte rejection wording and upload a real decodable JPEG (a zero-buffer is now rejected at the boundary). - ddos: open SSE via the single-use /stream/ticket flow, not the dead ?token=. - recover-page / join: the PIN field auto-submits on the 4th digit, so don't race an explicit submit click against the ensuing navigation. - gestures-doubletap / sheet-escape: target the lightbox by aria-labelledby and anchor the radio accessible-name match at the start (gated dialog semantics). playwright.config: camera/mic/clipboard are Chromium-only permissions (they threw "Unknown permission: camera" on firefox/webkit and failed the test at context creation); grant them per-Chromium-project. firefox-android drops the unsupported isMobile flag. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
141 lines
6.7 KiB
TypeScript
141 lines
6.7 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 behavior (documented finding if accepted)', async ({ guest }) => {
|
||
const g = await guest('ZeroByte');
|
||
const res = await uploadRaw(g.jwt, new Uint8Array(0), { filename: 'empty.jpg', contentType: 'image/jpeg' });
|
||
// Current backend accepts zero-byte JPEGs (201) because the magic-byte check
|
||
// returns None for empty input and short-circuits validation. Documented as
|
||
// a finding — the compression worker should reject them downstream, but
|
||
// a 400 at the upload boundary would be cleaner.
|
||
expect([201, 400]).toContain(res.status);
|
||
if (res.status === 201) {
|
||
console.warn('[finding] zero-byte uploads pass /api/v1/upload — consider rejecting empty bodies upfront.');
|
||
}
|
||
});
|
||
|
||
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',
|
||
});
|
||
expect([201, 400]).toContain(res.status);
|
||
// Critical assertion: the server must not have created or read /etc/passwd. We can't directly
|
||
// probe the container, so we settle for the upload-response-doesn't-leak path check below.
|
||
if (res.status === 201) {
|
||
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 |