Files
EventSnap/e2e/specs/07-adversarial/file-upload-attacks.spec.ts
fabi 02971f3186 test(e2e): de-vacuum security tests; add quota, authz-sweep, keepsake-regen coverage
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>
2026-07-15 07:26:40 +02:00

148 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';
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.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/);
});
});