fix(security): scope checkpoint routes to session — close 2 IDORs (v2.7.2)
Flagged by the automated push security review on v2.7.1. - GET /checkpoints?chat_id= : the chat_id branch filtered by chat_id alone (any session's chat_id read its checkpoints). Now joins chats and gates on chats.session_id. - restoreCheckpoint scope guard was fail-open: `cp.session_id && cp.session_id !== sessionId` fell through on a null denormalized session_id, allowing a cross-session restore (worktree reset + transcript trim). Now resolves the owning session via the checkpoint's chat and denies on missing/mismatch. - Adds a DB-integration regression for the null-session_id cross-session case. Both scope authoritatively through chats.session_id (checkpoints.session_id is a nullable hint). Coder suite 234 passing; 7/7 checkpoint tests (incl. the regression) against live postgres+git; typecheck clean. Hotfix on v2.7.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,18 +26,24 @@ export function registerCheckpointRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
return { error: 'session not found' };
|
||||
}
|
||||
|
||||
// Scope authoritatively through chats.session_id (always set) — NOT the
|
||||
// denormalized checkpoints.session_id (nullable). The chat_id branch must
|
||||
// still be session-gated or it's an IDOR (any session's chat_id reads its
|
||||
// checkpoints).
|
||||
const rows = chatId
|
||||
? await sql<{ id: string; chat_id: string; message_id: string | null; label: string | null; created_at: Date }[]>`
|
||||
SELECT id, chat_id, message_id, label, created_at
|
||||
FROM checkpoints
|
||||
WHERE chat_id = ${chatId}
|
||||
ORDER BY created_at
|
||||
SELECT cp.id, cp.chat_id, cp.message_id, cp.label, cp.created_at
|
||||
FROM checkpoints cp
|
||||
JOIN chats c ON c.id = cp.chat_id
|
||||
WHERE cp.chat_id = ${chatId} AND c.session_id = ${sessionId}
|
||||
ORDER BY cp.created_at
|
||||
`
|
||||
: await sql<{ id: string; chat_id: string; message_id: string | null; label: string | null; created_at: Date }[]>`
|
||||
SELECT id, chat_id, message_id, label, created_at
|
||||
FROM checkpoints
|
||||
WHERE session_id = ${sessionId}
|
||||
ORDER BY created_at
|
||||
SELECT cp.id, cp.chat_id, cp.message_id, cp.label, cp.created_at
|
||||
FROM checkpoints cp
|
||||
JOIN chats c ON c.id = cp.chat_id
|
||||
WHERE c.session_id = ${sessionId}
|
||||
ORDER BY cp.created_at
|
||||
`;
|
||||
return rows;
|
||||
},
|
||||
|
||||
@@ -233,4 +233,20 @@ describe.runIf(!!process.env.DATABASE_URL)('checkpoint create + restore (DB + gi
|
||||
).rejects.toBeInstanceOf(CheckpointNotFoundError);
|
||||
await sql`DELETE FROM checkpoints WHERE id = ${cp!.id}`;
|
||||
});
|
||||
|
||||
it('restoreCheckpoint denies a NULL-session_id checkpoint from another session (no fail-open IDOR)', async () => {
|
||||
// Regression for the fail-open authorization bug: a checkpoint row whose
|
||||
// denormalized session_id is NULL must STILL be scoped via its chat's owning
|
||||
// session (chats.session_id), not skipped. The old guard `cp.session_id &&
|
||||
// cp.session_id !== sessionId` fell through on NULL → cross-session restore.
|
||||
const [row] = await sql<{ id: string }[]>`
|
||||
INSERT INTO checkpoints (chat_id, session_id, message_id, commit_sha)
|
||||
VALUES (${chatId}, NULL, NULL, 'deadbeef')
|
||||
RETURNING id
|
||||
`;
|
||||
await expect(
|
||||
restoreCheckpoint(sql, row!.id, { sessionId: '22222222-2222-2222-2222-222222222222' }),
|
||||
).rejects.toBeInstanceOf(CheckpointNotFoundError);
|
||||
await sql`DELETE FROM checkpoints WHERE id = ${row!.id}`;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,8 +159,17 @@ export async function restoreCheckpoint(
|
||||
if (!cp) {
|
||||
throw new CheckpointNotFoundError('checkpoint not found');
|
||||
}
|
||||
if (opts?.sessionId && cp.session_id && cp.session_id !== opts.sessionId) {
|
||||
throw new CheckpointNotFoundError('checkpoint not in session');
|
||||
// Authorization scope (fail-safe): the checkpoint's chat must belong to the
|
||||
// requested session. cp.session_id is a denormalized hint that may be null, so
|
||||
// gating on it directly fails open — resolve the owning session via chats
|
||||
// (authoritative; chat_id is NOT NULL) and deny on any mismatch or missing row.
|
||||
if (opts?.sessionId) {
|
||||
const [owner] = await sql<{ session_id: string | null }[]>`
|
||||
SELECT session_id FROM chats WHERE id = ${cp.chat_id}
|
||||
`;
|
||||
if (!owner || owner.session_id !== opts.sessionId) {
|
||||
throw new CheckpointNotFoundError('checkpoint not in session');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Resolve the worktree path (by worktree_id, else the session's active one).
|
||||
|
||||
Reference in New Issue
Block a user