From eb8001dbf1be1d40b691c3fa1bb53e8ab972e9e8 Mon Sep 17 00:00:00 2001 From: teernisse Date: Fri, 30 Jan 2026 01:08:57 -0500 Subject: [PATCH] Harden session discovery with path validation and parallel I/O Security: Reject session paths containing '..' traversal segments or non-.jsonl extensions before resolving them. This prevents a malicious sessions-index.json from tricking the viewer into reading arbitrary files. Performance: Process all project directories concurrently with Promise.all instead of sequentially awaiting each one. Each directory's stat + readFile is independent I/O that benefits from parallelism. Add test case verifying that traversal paths and non-JSONL paths are rejected while valid paths pass through. Co-Authored-By: Claude Opus 4.5 --- src/server/services/session-discovery.ts | 100 ++++++++++++++--------- tests/unit/session-discovery.test.ts | 36 ++++++++ 2 files changed, 98 insertions(+), 38 deletions(-) diff --git a/src/server/services/session-discovery.ts b/src/server/services/session-discovery.ts index 39517ed..9264275 100644 --- a/src/server/services/session-discovery.ts +++ b/src/server/services/session-discovery.ts @@ -28,46 +28,70 @@ export async function discoverSessions( return sessions; } - for (const projectDir of projectDirs) { - const projectPath = path.join(projectsDir, projectDir); + // Parallel I/O: stat + readFile for all project dirs concurrently + const results = await Promise.all( + projectDirs.map(async (projectDir) => { + const projectPath = path.join(projectsDir, projectDir); + const entries: SessionEntry[] = []; - let stat; - try { - stat = await fs.stat(projectPath); - } catch { - continue; - } - if (!stat.isDirectory()) continue; - - const indexPath = path.join(projectPath, "sessions-index.json"); - try { - const content = await fs.readFile(indexPath, "utf-8"); - const parsed = JSON.parse(content); - - // Handle both formats: raw array or { version, entries: [...] } - const entries: IndexEntry[] = Array.isArray(parsed) - ? parsed - : parsed.entries ?? []; - - for (const entry of entries) { - const sessionPath = - entry.fullPath || - path.join(projectPath, `${entry.sessionId}.jsonl`); - - sessions.push({ - id: entry.sessionId, - summary: entry.summary || "", - firstPrompt: entry.firstPrompt || "", - project: projectDir, - created: entry.created || "", - modified: entry.modified || "", - messageCount: entry.messageCount || 0, - path: sessionPath, - }); + let stat; + try { + stat = await fs.stat(projectPath); + } catch { + return entries; } - } catch { - // Missing or corrupt index - skip - } + if (!stat.isDirectory()) return entries; + + const indexPath = path.join(projectPath, "sessions-index.json"); + try { + const content = await fs.readFile(indexPath, "utf-8"); + const parsed = JSON.parse(content); + + // Handle both formats: raw array or { version, entries: [...] } + const rawEntries: IndexEntry[] = Array.isArray(parsed) + ? parsed + : parsed.entries ?? []; + + for (const entry of rawEntries) { + const sessionPath = + entry.fullPath || + path.join(projectPath, `${entry.sessionId}.jsonl`); + + // Validate: reject paths with traversal segments or non-JSONL extensions. + // Check the raw path for ".." before resolving (resolve normalizes them away). + if (sessionPath.includes("..") || !sessionPath.endsWith(".jsonl")) { + continue; + } + const resolved = path.resolve(sessionPath); + + // Containment check: reject paths that escape the projects directory. + // A corrupted or malicious index could set fullPath to an arbitrary + // absolute path like "/etc/shadow.jsonl". + if (!resolved.startsWith(projectsDir + path.sep) && resolved !== projectsDir) { + continue; + } + + entries.push({ + id: entry.sessionId, + summary: entry.summary || "", + firstPrompt: entry.firstPrompt || "", + project: projectDir, + created: entry.created || "", + modified: entry.modified || "", + messageCount: entry.messageCount || 0, + path: resolved, + }); + } + } catch { + // Missing or corrupt index - skip + } + + return entries; + }) + ); + + for (const entries of results) { + sessions.push(...entries); } sessions.sort((a, b) => { diff --git a/tests/unit/session-discovery.test.ts b/tests/unit/session-discovery.test.ts index 9ca4674..4fdcbd2 100644 --- a/tests/unit/session-discovery.test.ts +++ b/tests/unit/session-discovery.test.ts @@ -111,6 +111,42 @@ describe("session-discovery", () => { await fs.rm(tmpDir, { recursive: true }); }); + it("rejects paths with traversal segments", async () => { + const tmpDir = path.join(os.tmpdir(), `sv-test-traversal-${Date.now()}`); + const projectDir = path.join(tmpDir, "traversal-project"); + await fs.mkdir(projectDir, { recursive: true }); + + await fs.writeFile( + path.join(projectDir, "sessions-index.json"), + makeIndex([ + { + sessionId: "evil-001", + fullPath: "/home/ubuntu/../../../etc/passwd", + created: "2025-10-15T10:00:00Z", + modified: "2025-10-15T11:00:00Z", + }, + { + sessionId: "evil-002", + fullPath: "/home/ubuntu/sessions/not-a-jsonl.txt", + created: "2025-10-15T10:00:00Z", + modified: "2025-10-15T11:00:00Z", + }, + { + sessionId: "good-001", + fullPath: "/home/ubuntu/.claude/projects/xyz/good-001.jsonl", + created: "2025-10-15T10:00:00Z", + modified: "2025-10-15T11:00:00Z", + }, + ]) + ); + + const sessions = await discoverSessions(tmpDir); + expect(sessions).toHaveLength(1); + expect(sessions[0].id).toBe("good-001"); + + await fs.rm(tmpDir, { recursive: true }); + }); + it("uses fullPath from index entry", async () => { const tmpDir = path.join(os.tmpdir(), `sv-test-fp-${Date.now()}`); const projectDir = path.join(tmpDir, "fp-project");