Skip to content

Commit 00a9477

Browse files
oratisclaude
andcommitted
fix(sessions): refuse a traversal id before the recursive delete runs
`deleteSession` ends in `rm -rf <root>/<id>`, and the id is a string the caller was handed. `..` passes the `^[a-zA-Z0-9._-]+$` check that both this path and the thread store rely on — it is spelled entirely in characters an id may legitimately contain — and `join(root, '..')` is the directory above the sessions root. Every in-tree caller validates first: `ProtocolRuntime.deleteThread` 404s on a thread that does not exist, and `CanonicalThreadStore.delete` runs `FileThreadStore.delete` (which validates) before the session projection. So this is not reachable today. It is also a recursive delete resolved from an untrusted string, one refactor away from being the only check, and AGENTS.md asks for adversarial tests exactly here. `deleteSession` now rejects a malformed id before removing anything, and `validThreadId` stops admitting `.` and `..` — harmless while every path built from an id had a suffix appended, not harmless now that one reaches a directory removal. The fixture nests its sessions root deep enough that every hostile id still resolves inside the temp directory: confirming this bug by deleting the guard walked `rm -rf` up into $TMPDIR, which is not something a test should be able to do by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 59ecb01 commit 00a9477

4 files changed

Lines changed: 92 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2929
existing `threadManagement` capability, with the local writer kept as the
3030
fallback for a sidecar too old to know the method.
3131

32-
3332
### 🔒 Security
3433

3534
- **A sub-agent did not inherit the file contract.** The `Task` delegation
@@ -60,6 +59,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6059
thread removes both its protocol snapshot and its canonical session
6160
projection: `list` reads both, so removing one left the row reappearing on the
6261
next refresh as an empty session that could not be opened.
62+
- `deleteSession` refuses a session id that is not a single path segment, before
63+
removing anything. It ends in a recursive delete of `<root>/<id>`, and `..`
64+
the id that resolves to the directory _above_ the sessions root — is spelled
65+
entirely in characters an id may legitimately contain, so the character-class
66+
check both it and the thread store relied on admitted it. Every in-tree caller
67+
validates first; a delete this destructive should not depend on that.
6368
- **`Grep` over a single file no longer prefixes every line with a colon.**
6469
ripgrep omits the filename when the search path is one _file_ — there is
6570
nothing to disambiguate — so its `--null` output carries no NUL, and rejoining

apps/server/src/store.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import type { ThreadSnapshot, ThreadStore } from '@deepcode/protocol';
99
import { historyFromThread } from './runtime-executor.js';
1010

1111
function validThreadId(threadId: string): boolean {
12+
// `.` and `..` are spelled entirely in characters an id may legitimately
13+
// contain, so the class alone admits them. Harmless while every path built
14+
// from an id had a suffix appended; not harmless now that `delete` reaches a
15+
// recursive removal of `<root>/<id>`.
16+
if (threadId === '.' || threadId === '..') return false;
1217
return /^[a-zA-Z0-9._-]+$/.test(threadId);
1318
}
1419

packages/core/src/sessions/storage.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
1+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
22
import { tmpdir } from 'node:os';
33
import { join } from 'node:path';
44
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
55
import {
66
appendMessage,
7+
deleteSession,
78
listSessions,
89
newSessionId,
910
readMessages,
@@ -259,3 +260,56 @@ describe('session storage', () => {
259260
await expect(readFile(files.jsonlPath, 'utf8')).rejects.toThrow();
260261
});
261262
});
263+
264+
// `deleteSession` ends in a recursive `rm` of `<root>/<id>`, so the id is a path
265+
// segment resolved from a string the caller received. AGENTS.md asks for
266+
// adversarial tests where a mistake destroys user work; this is that place.
267+
describe('deleteSession', () => {
268+
let root: string;
269+
beforeEach(async () => {
270+
root = await mkdtemp(join(tmpdir(), 'deepcode-delete-'));
271+
});
272+
afterEach(async () => {
273+
await rm(root, { recursive: true, force: true });
274+
});
275+
276+
it('removes every file the session owns', async () => {
277+
const id = newSessionId();
278+
await writeMeta(root, { id, cwd: '/w', createdAt: 'x', updatedAt: 'x' });
279+
await appendMessage(root, id, { role: 'user', content: [{ type: 'text', text: 'hi' }] });
280+
const files = sessionFiles(root, id);
281+
282+
await deleteSession(root, id);
283+
284+
await expect(readFile(files.metaPath, 'utf8')).rejects.toThrow();
285+
await expect(readFile(files.jsonlPath, 'utf8')).rejects.toThrow();
286+
expect(await listSessions(root)).toHaveLength(0);
287+
});
288+
289+
it('is not an error when the files are already gone', async () => {
290+
await expect(deleteSession(root, newSessionId())).resolves.toBeUndefined();
291+
});
292+
293+
it.each(['..', '.', '', '../..', 'a/b', '../escape'])(
294+
'refuses the id %j instead of resolving it into a path',
295+
async (id) => {
296+
// `..` is the one that matters: it is spelled entirely in characters an
297+
// id may legitimately contain, so a character-class check alone admits
298+
// it — and `join(sessionsRoot, '..')` is the directory above it.
299+
//
300+
// The sessions root is nested deep enough that every id here still
301+
// resolves inside `root`. Without that, a run with the guard removed
302+
// walks `rm -rf` up out of the fixture and into the system temp
303+
// directory — which is how this was confirmed, and not something a test
304+
// should be able to do by accident.
305+
const sessionsRoot = join(root, 'deep', 'deeper', 'sessions');
306+
await mkdir(sessionsRoot, { recursive: true });
307+
const guarded = join(root, 'keep-me.txt');
308+
await writeFile(guarded, 'not mine to delete', 'utf8');
309+
310+
await expect(deleteSession(sessionsRoot, id)).rejects.toThrow(/invalid id/);
311+
312+
expect(await readFile(guarded, 'utf8')).toBe('not mine to delete');
313+
},
314+
);
315+
});

packages/core/src/sessions/storage.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,24 @@ export interface SessionFiles {
6868
snapshotsDir: string;
6969
}
7070

71+
/**
72+
* A session id that is safe to interpolate into a path.
73+
*
74+
* `deleteSession` removes a whole directory recursively, so the id has to be a
75+
* single path segment and cannot be a traversal. `..` is the one that matters:
76+
* it is composed entirely of characters an id may legitimately contain, so a
77+
* character-class check alone lets it through — and `join(root, '..')` is the
78+
* parent of the sessions root.
79+
*/
80+
function validSessionId(sessionId: string): boolean {
81+
return (
82+
sessionId !== '' &&
83+
sessionId !== '.' &&
84+
sessionId !== '..' &&
85+
/^[a-zA-Z0-9._-]+$/.test(sessionId)
86+
);
87+
}
88+
7189
/**
7290
* Irreversibly remove every file belonging to one session.
7391
*
@@ -79,8 +97,16 @@ export interface SessionFiles {
7997
* Missing files are not an error. The caller has already established that the
8098
* session exists, and a delete that fails partway through because one of five
8199
* paths was already gone leaves the user unable to finish it.
100+
*
101+
* A malformed id *is* an error, and is refused before anything is removed. The
102+
* callers in-tree all validate first, so this can only fire on a programming
103+
* mistake — which is exactly when a recursive delete resolved from an untrusted
104+
* string must not proceed on the strength of somebody else having checked.
82105
*/
83106
export async function deleteSession(root: string, sessionId: string): Promise<void> {
107+
if (!validSessionId(sessionId)) {
108+
throw new Error(`Refusing to delete session with invalid id: ${JSON.stringify(sessionId)}`);
109+
}
84110
const files = sessionFiles(root, sessionId);
85111
for (const path of [
86112
files.jsonlPath,

0 commit comments

Comments
 (0)