/** * 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); }); test('the preview is revoked when the UPLOADER is banned (not just on delete)', async ({ host, api, guest, }) => { test.setTimeout(30_000); // The header of this file claims delete AND ban-hide both revoke access, but only delete was // ever exercised. A ban hides the user's content everywhere (the visibility check filters // `is_banned` inside `find_by_id_visible`, which gates preview AND thumbnail identically), and // its whole point is that a direct-URL holder loses the image — so it must 404 the gated // preview too, exactly like a delete. const offender = await guest('BannedUploader'); const id = await seedUpload(offender.jwt, { caption: 'to be hidden' }); // Wait for the compression worker to produce the preview. await expect .poll( async () => { const row = (await api.getFeed(host.jwt)).uploads?.find((u: any) => u.id === id); return row?.preview_url; }, { timeout: 20_000, intervals: [500] } ) .toBe(`/api/v1/upload/${id}/preview`); // Served while the uploader is in good standing. expect((await fetch(`${BASE}/api/v1/upload/${id}/preview`)).status).toBe(200); // Ban the uploader (default: hide their uploads). await api.banUser(host.jwt, offender.userId); // Must now 404 — the direct-URL holder loses the image, same as a takedown. expect( (await fetch(`${BASE}/api/v1/upload/${id}/preview`)).status, "a banned uploader's preview must be revoked" ).toBe(404); }); });