diff --git a/src/providers/claude.ts b/src/providers/claude.ts index cb8caef..4da39d3 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -1,4 +1,4 @@ -import { readdir, stat } from 'fs/promises' +import { lstat, readdir } from 'fs/promises' import { basename, join } from 'path' import { homedir } from 'os' @@ -35,20 +35,24 @@ function getDesktopSessionsDir(): string { async function findDesktopProjectDirs(base: string): Promise { const results: string[] = [] + // `lstat` (not `stat`) so we never descend into symbolic links. A malicious or + // misconfigured session tree could otherwise redirect us anywhere on the filesystem + // the calling user can read — outside the Claude sessions sandbox by design. async function walk(dir: string, depth: number): Promise { if (depth > 8) return const entries = await readdir(dir).catch(() => []) for (const entry of entries) { if (entry === 'node_modules' || entry === '.git') continue const full = join(dir, entry) - const s = await stat(full).catch(() => null) - if (!s?.isDirectory()) continue + const s = await lstat(full).catch(() => null) + if (!s || s.isSymbolicLink() || !s.isDirectory()) continue if (entry === 'projects') { const projectDirs = await readdir(full).catch(() => []) for (const pd of projectDirs) { const pdFull = join(full, pd) - const pdStat = await stat(pdFull).catch(() => null) - if (pdStat?.isDirectory()) results.push(pdFull) + const pdStat = await lstat(pdFull).catch(() => null) + if (!pdStat || pdStat.isSymbolicLink() || !pdStat.isDirectory()) continue + results.push(pdFull) } } else { await walk(full, depth + 1) @@ -83,10 +87,9 @@ export const claude: Provider = { const entries = await readdir(projectsDir) for (const dirName of entries) { const dirPath = join(projectsDir, dirName) - const dirStat = await stat(dirPath).catch(() => null) - if (dirStat?.isDirectory()) { - sources.push({ path: dirPath, project: dirName, provider: 'claude' }) - } + const dirStat = await lstat(dirPath).catch(() => null) + if (!dirStat || dirStat.isSymbolicLink() || !dirStat.isDirectory()) continue + sources.push({ path: dirPath, project: dirName, provider: 'claude' }) } } catch {} diff --git a/tests/providers/claude-symlink.test.ts b/tests/providers/claude-symlink.test.ts new file mode 100644 index 0000000..446f583 --- /dev/null +++ b/tests/providers/claude-symlink.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm, symlink } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { claude } from '../../src/providers/claude.js' + +let tmpDir: string +let claudeDir: string +let realTarget: string +let originalConfigDir: string | undefined +let originalHome: string | undefined + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'claude-symlink-test-')) + claudeDir = join(tmpDir, 'claude-config') + realTarget = join(tmpDir, 'outside-the-sandbox') + await mkdir(join(claudeDir, 'projects'), { recursive: true }) + await mkdir(realTarget, { recursive: true }) + await writeFile(join(realTarget, 'sneaky.jsonl'), '{"type":"hello"}\n') + + originalConfigDir = process.env['CLAUDE_CONFIG_DIR'] + originalHome = process.env['HOME'] + process.env['CLAUDE_CONFIG_DIR'] = claudeDir + process.env['HOME'] = tmpDir +}) + +afterEach(async () => { + if (originalConfigDir === undefined) delete process.env['CLAUDE_CONFIG_DIR'] + else process.env['CLAUDE_CONFIG_DIR'] = originalConfigDir + if (originalHome === undefined) delete process.env['HOME'] + else process.env['HOME'] = originalHome + await rm(tmpDir, { recursive: true, force: true }) +}) + +describe('claude provider — symlink protection in session discovery', () => { + it('skips symbolic-link entries pointing outside the projects dir', async () => { + const realProjectDir = join(claudeDir, 'projects', 'real-project') + await mkdir(realProjectDir, { recursive: true }) + await symlink(realTarget, join(claudeDir, 'projects', 'shady-link')) + + const sources = await claude.discoverSessions() + const claudeSources = sources.filter(s => s.provider === 'claude') + const projects = claudeSources.map(s => s.project) + + expect(projects).toContain('real-project') + expect(projects).not.toContain('shady-link') + for (const s of claudeSources) { + expect(s.path.startsWith(realTarget)).toBe(false) + } + }) + + it('still discovers regular project directories', async () => { + await mkdir(join(claudeDir, 'projects', 'plain'), { recursive: true }) + + const sources = await claude.discoverSessions() + const claudeSources = sources.filter(s => s.provider === 'claude') + const projects = claudeSources.map(s => s.project) + + expect(projects).toContain('plain') + }) +})