fix(activities): close existence oracle on PATCH /:id/sort

The sort endpoint validated existence with a bare
`SELECT 1 FROM activities WHERE id = ?`, ignoring visibility. A
logged-in attacker could PATCH /sort with any UUID and distinguish
"private id exists, owned by someone else" (200) from "id doesn't
exist" (404), letting them enumerate private activity ids.

Apply the same visibility filter as GET /:id, toggleDone, and
toggleFiling: private requires owner; friends requires mutual-friend
+ no block in either direction; hidden rows return 404, not 403.

Regression test added in tests/activities.test.ts.

Surfaced by /audit security (HIGH severity).
This commit is contained in:
Ole-Morten Duesund 2026-05-25 20:34:50 +02:00
commit 0e5bf0a035
2 changed files with 61 additions and 7 deletions

View file

@ -556,13 +556,32 @@ activitiesRoutes.patch('/:id/sort', requireAuth, async (c) => {
return c.json({ error: 'missing:position' }, 400);
}
const db = getDb();
// Confirm the activity exists AND the viewer can see it. Anything else is
// a 404 — we don't want callers persisting positions for activities they
// can't see, even though it wouldn't surface anywhere visible.
const visible = db
.prepare('SELECT 1 FROM activities WHERE id = ?')
.get(id);
if (!visible) return c.json({ error: 'not_found' }, 404);
// Apply the same visibility filter as GET /:id and the list endpoint so
// sort doesn't double as an existence oracle for private / friends-only
// activity ids. Hidden rows return 404 (not 403). Earlier this endpoint
// only did a bare `SELECT 1 FROM activities WHERE id = ?` — surfaced by
// /audit security as a HIGH severity finding.
const row = db
.prepare('SELECT visibility, owner_id FROM activities WHERE id = ?')
.get(id) as { visibility: Visibility; owner_id: string } | null;
if (!row) return c.json({ error: 'not_found' }, 404);
if (row.visibility === 'private' && row.owner_id !== userId) {
return c.json({ error: 'not_found' }, 404);
}
if (row.visibility === 'friends' && row.owner_id !== userId) {
const isFriend = !!db
.prepare('SELECT 1 FROM friends WHERE owner_id = ? AND friend_id = ?')
.get(row.owner_id, userId);
if (!isFriend) return c.json({ error: 'not_found' }, 404);
const blocked = !!db
.prepare(`
SELECT 1 FROM user_blocks
WHERE (blocker_id = ? AND blocked_id = ?)
OR (blocker_id = ? AND blocked_id = ?)
`)
.get(row.owner_id, userId, userId, row.owner_id);
if (blocked) return c.json({ error: 'not_found' }, 404);
}
db.prepare(`
INSERT INTO user_activity_sort (user_id, activity_id, position) VALUES (?, ?, ?)