/** * Security fix F2: preview/thumbnail images are now served through a visibility-checked * alias (/api/v1/upload/{id}/preview) instead of the unauthenticated /media ServeDir, * so moderation (delete / ban-hide) actually revokes access to the displayed image — * not just the full-res original. Direct /media/previews access is blocked. */ import { test, expect } from '../../fixtures/test'; import { seedUpload } from '../../helpers/seed'; const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; test.describe('Media gating — moderation revokes preview access (F2)', () => { test('preview served via gated alias, blocked directly, and 404 after delete', async ({ host, api, }) => { test.setTimeout(30_000); const id = await seedUpload(host.jwt, { caption: 'gated' }); // Wait for the compression worker to produce the preview — the feed exposes // `preview_url` only once `preview_path` is set. let previewUrl: string | undefined; await expect .poll( async () => { const feed = await api.getFeed(host.jwt); const row = (feed.uploads ?? []).find((u: any) => u.id === id); previewUrl = row?.preview_url ?? undefined; return previewUrl; }, { timeout: 20_000, intervals: [500] } ) .toBeTruthy(); // Feed now emits the gated alias, not a /media path. expect(previewUrl).toBe(`/api/v1/upload/${id}/preview`); // The gated alias serves the image with the app-layer nosniff header. const ok = await fetch(`${BASE}${previewUrl}`); expect(ok.status).toBe(200); expect(ok.headers.get('content-type')).toContain('image/jpeg'); // App sets nosniff (F6); the edge proxy may also set it → value can be doubled. expect(ok.headers.get('x-content-type-options')).toContain('nosniff'); // Direct /media access is blocked (404) — the alias is the only way in. const direct = await fetch(`${BASE}/media/previews/${id}.jpg`); expect(direct.status, 'direct /media/previews must be blocked').toBe(404); // Host deletes the upload → the preview must stop being served. const del = await fetch(`${BASE}/api/v1/host/upload/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${host.jwt}` }, }); expect(del.status).toBe(204); const afterDelete = await fetch(`${BASE}/api/v1/upload/${id}/preview`); expect(afterDelete.status, 'moderation must revoke preview access').toBe(404); }); });