/** * Phase 2 adversarial — deeper authorization escalation paths. * * Complements the foundational 403/401 checks in 05-admin/authorization.spec.ts * with cross-user and banned-user scenarios that span multiple resources. */ import { test, expect } from '../../fixtures/test'; import { seedUpload, seedComment, listComments, findFeedRow } from '../../helpers/seed'; import { BASE } from '../../helpers/env'; test.describe('Adversarial — deep authorization', () => { // IDOR: user B must not be able to delete user A's REAL comment. This exercises the // ownership guard (`comment.user_id != auth.user_id` → 403) — the previous version fired // at the all-zeros UUID, which 404s at the lookup BEFORE that guard runs, so it never // tested authorization at all. test("user B cannot delete user A's comment (real resource → 403, comment survives)", async ({ guest, }) => { const a = await guest('CommentOwnerA'); const b = await guest('AttackerB'); const uploadId = await seedUpload(a.jwt); const commentId = await seedComment(a.jwt, uploadId, 'A owns this'); const res = await fetch(`${BASE}/api/v1/comment/${commentId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${b.jwt}` }, }); // Must be 403 specifically — the comment exists and is in B's event, so a 404 would // mean the ownership check was skipped/reordered. expect(res.status).toBe(403); // No state change: the comment is still there. const after = await listComments(a.jwt, uploadId); expect(after.some((c: any) => c.id === commentId)).toBe(true); // Control: the real owner CAN delete it (proves the 403 was about identity, not a broken route). const ownerDel = await fetch(`${BASE}/api/v1/comment/${commentId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${a.jwt}` }, }); expect(ownerDel.status).toBe(204); }); // IDOR: user B must not be able to delete user A's REAL upload. test("user B cannot delete user A's upload (403, upload survives)", async ({ guest, db }) => { const a = await guest('UploadOwnerA'); const b = await guest('AttackerB2'); const uploadId = await seedUpload(a.jwt); expect(await db.countUploadsForUser(a.userId)).toBe(1); const res = await fetch(`${BASE}/api/v1/upload/${uploadId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${b.jwt}` }, }); expect(res.status).toBe(403); // No state change: A's upload is still present (not soft-deleted). expect(await db.countUploadsForUser(a.userId)).toBe(1); }); // IDOR: user B must not be able to edit (re-caption / re-tag) user A's upload. test("user B cannot edit user A's upload caption (403, caption unchanged)", async ({ guest }) => { const a = await guest('UploadOwnerA2'); const b = await guest('AttackerB3'); // seedUpload marks compression done, so the upload is feed-visible for the read-back. const uploadId = await seedUpload(a.jwt, { caption: 'original caption' }); const res = await fetch(`${BASE}/api/v1/upload/${uploadId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${b.jwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ caption: 'hacked by B' }), }); expect(res.status).toBe(403); // No state change: the caption A set is intact. const feedRes = await fetch(`${BASE}/api/v1/feed`, { headers: { Authorization: `Bearer ${a.jwt}` }, }); const row = findFeedRow(await feedRes.json(), uploadId); expect(row?.caption).toBe('original caption'); }); // These two fire at a REAL upload, and demand exactly 403. // // They used to POST to the all-zeros UUID and accept `[403, 404]`. Both handlers check // `user.is_banned` BEFORE they look the upload up (social.rs) — so the 404 came from the // *lookup*, not the guard. Delete the ban check entirely and the request still 404s on the // nonexistent upload, and both tests still passed. They were the only coverage of // ban-blocks-like and ban-blocks-comment, and they guarded nothing. test('banned user cannot toggle a like', async ({ api, host, guest }) => { const target = await guest('BannedLike'); const uploadId = await seedUpload(host.jwt, { caption: 'likeable' }); await api.banUser(host.jwt, target.userId); const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/like`, { method: 'POST', headers: { Authorization: `Bearer ${target.jwt}` }, }); expect(res.status, 'a banned user must be Forbidden, not merely miss the resource').toBe(403); }); test('banned user cannot post a comment', async ({ api, host, guest }) => { const target = await guest('BannedComment'); const uploadId = await seedUpload(host.jwt, { caption: 'commentable' }); await api.banUser(host.jwt, target.userId); const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, { method: 'POST', headers: { Authorization: `Bearer ${target.jwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ body: 'should be rejected' }), }); expect(res.status, 'a banned user must be Forbidden, not merely miss the resource').toBe(403); // ...and nothing was written. expect(await listComments(host.jwt, uploadId)).toHaveLength(0); }); test('banned user can still read the feed (read-only access preserved)', async ({ api, host, guest, }) => { const target = await guest('BannedRead'); await api.banUser(host.jwt, target.userId); const res = await fetch(`${BASE}/api/v1/feed`, { headers: { Authorization: `Bearer ${target.jwt}` }, }); // The journey docs explicitly state banned users keep read access. expect(res.status).toBe(200); }); test("host cannot delete another host's session via /api/v1/session", async ({ api, host, guest, }) => { const otherHost = await guest('OtherHost'); // Promote so they have a host JWT to play with. await api.setRole(host.jwt, otherHost.userId, 'host'); // The /session DELETE endpoint deletes the caller's own session by token hash. // A host cannot pass another host's token here (no way to authenticate as them), // so this is structurally safe — we assert by trying to delete with the wrong // Authorization header and checking that only the caller's session is gone. await api.logout(host.jwt); const stillWorks = await fetch(`${BASE}/api/v1/me/context`, { headers: { Authorization: `Bearer ${otherHost.jwt}` }, }); expect(stillWorks.status).toBe(200); }); // Privilege escalation, tested against REAL targets. // // The old version PATCHed the all-zeros UUID and accepted `[400, 403, 404]`. The role whitelist // rejects "admin" with a 400 before the target is ever looked up — so adding `"admin"` to the // whitelist would make the request 404 on the nonexistent user instead, which was in the accepted // list. It never promoted anyone, never targeted *oneself*, and could not detect escalation. test('a host cannot promote a real guest to admin', async ({ api, host, guest, adminToken }) => { const victim = await guest('EscalationTarget'); const res = await fetch(`${BASE}/api/v1/host/users/${victim.userId}/role`, { method: 'PATCH', headers: { Authorization: `Bearer ${host.jwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ role: 'admin' }), }); expect(res.status).not.toBe(204); expect(res.status).not.toBe(200); // The assertion that actually matters: nobody got promoted. const users = await api.listUsers(adminToken); const row = users.find((u: any) => u.id === victim.userId); expect(row?.role, 'the guest must NOT have become an admin').not.toBe('admin'); }); test('a host cannot promote THEMSELVES to admin', async ({ api, host, adminToken }) => { const res = await fetch(`${BASE}/api/v1/host/users/${host.userId}/role`, { method: 'PATCH', headers: { Authorization: `Bearer ${host.jwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ role: 'admin' }), }); expect(res.status).not.toBe(204); expect(res.status).not.toBe(200); const users = await api.listUsers(adminToken); const me = users.find((u: any) => u.id === host.userId); expect(me?.role, 'the host must NOT have self-promoted to admin').not.toBe('admin'); }); });