An audit found tests that pass on broken code. The dominant pattern: fire a security
assertion at the all-zeros UUID and accept [403, 404] — the 404 comes from the resource
LOOKUP, not the guard, so the guard can be deleted and the test still passes. Repaired to
use real resources and demand exactly 403:
- banned-user cannot like / comment (the only coverage of those ban invariants)
- host cannot promote a real guest (or self) to admin — asserts nobody's role changed
- IDOR comment-delete already used a real resource; kept
Also inverted recovery.spec's "unknown name → nicht gefunden" test: that asserted the exact
account-enumeration oracle the F4 fix removed, so restoring the vuln would have made it
pass. Now: an unknown name must be byte-identical to a wrong PIN.
New coverage for paths that ran in production but in zero tests:
- quota.spec.ts: storage quota enforcement (413 over-limit, atomic increment under two
uploads held mid-body so both carry a stale total=0 — the real race; a naive Promise.all
version was itself vacuous and is documented as such). Proven to fail without the guard.
- authz-sweep.spec.ts: table-driven guest→403 / host→403 over ALL 19 privileged routes +
anonymous + __truncate. No live hole found; the whole surface is now locked.
- ban / unban / host-comment-delete AFTER release regenerate the keepsake (data-loss
paths that were dead under test); comment-delete mid-build doesn't strand the ZIP.
Lower-severity de-vacuuming: 10 MB comment test hit Caddy's 502 before the real 500-char
cap (now seeds a real upload, 501→400 / 500→201, mutation-verified); XSS name payloads
shortened under the 50-char cap so they actually store+render; ui-rendering XSS test now
proves the payload rendered before asserting no <b>; export page-object locators fixed to
the real "Download" label with a positive empty-state anchor; avatar palette spread test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
148 lines
7.3 KiB
TypeScript
148 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';
|
||
|
||
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 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 |