Skip to content

Commit cda42c8

Browse files
aaronjmarsaaronjmars
andauthored
fix(security): refuse to follow symlinks when writing /tmp session marker (colbymchenry#280)
`markSessionConsulted` writes `${tmpdir()}/codegraph-consulted-${hash}` on every `codegraph_context` call so external tooling can detect that an MCP session has consulted CodeGraph. The old `writeFileSync` followed symlinks unconditionally, so on a multi-user system any other local user could pre-create that marker path as a symlink pointing at a victim-writable file — the next codegraph context call would then overwrite the target's contents with the ISO timestamp string (CWE-59). The session-id hash gates predictability and makes opportunistic exploit infeasible on its own, but tmpdir() is world-writable (mode 1777 on Linux) and the proper pattern is to never follow links into a shared-prefix tmpfile. Switch to `openSync` with O_NOFOLLOW + mode 0o600. ELOOP from a planted symlink lands in the existing silent-fail catch — refuse to write rather than touch an attacker-chosen target. Detected by Aeon + manual review. Severity: medium CWE-59 (link following), CWE-732 (incorrect permission for critical resource) Co-authored-by: aaronjmars <aaron@aeon.local>
1 parent 5a09431 commit cda42c8

2 files changed

Lines changed: 123 additions & 3 deletions

File tree

__tests__/security.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,3 +533,94 @@ describe('Symlink Cycle Detection', () => {
533533
expect(files).toContain('src/valid.ts');
534534
});
535535
});
536+
537+
describe('Session marker symlink resistance', () => {
538+
// The marker write lives in src/mcp/tools.ts behind handleContext. We exercise
539+
// it end-to-end via ToolHandler.execute so the test exercises the same code
540+
// path Claude Code drives. The session id is per-test so other parallel test
541+
// runs can't collide with the marker file we plant a symlink at.
542+
const SESSION_ID = `cg-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
543+
const crypto = require('crypto') as typeof import('crypto');
544+
const hash = crypto.createHash('md5').update(SESSION_ID).digest('hex').slice(0, 16);
545+
const markerPath = path.join(os.tmpdir(), `codegraph-consulted-${hash}`);
546+
547+
let projectDir: string;
548+
let victimDir: string;
549+
let victimFile: string;
550+
551+
beforeEach(async () => {
552+
projectDir = createTempDir();
553+
victimDir = createTempDir();
554+
victimFile = path.join(victimDir, 'private.txt');
555+
fs.writeFileSync(victimFile, 'SECRET-DO-NOT-OVERWRITE\n');
556+
if (fs.existsSync(markerPath)) fs.unlinkSync(markerPath);
557+
558+
// A real .codegraph/ has to exist for handleContext to get past the
559+
// "not initialized" guard — index a tiny fixture so the call reaches the
560+
// marker write step rather than short-circuiting on missing project state.
561+
fs.writeFileSync(path.join(projectDir, 'a.ts'), 'export const x = 1;\n');
562+
const cg = await CodeGraph.init(projectDir);
563+
await cg.indexAll();
564+
cg.close();
565+
});
566+
567+
afterEach(() => {
568+
if (fs.existsSync(markerPath)) fs.unlinkSync(markerPath);
569+
cleanupTempDir(projectDir);
570+
cleanupTempDir(victimDir);
571+
});
572+
573+
it('does not follow a pre-planted symlink at the marker path', async () => {
574+
// Skip on platforms where the user can't create symlinks (Windows without
575+
// dev mode + admin). The CWE-59 risk we're guarding against doesn't apply
576+
// when symlinks aren't creatable, so the skip is correct, not a gap.
577+
try {
578+
fs.symlinkSync(victimFile, markerPath);
579+
} catch {
580+
return;
581+
}
582+
583+
const cg = await CodeGraph.open(projectDir);
584+
const handler = new ToolHandler(cg);
585+
process.env.CLAUDE_SESSION_ID = SESSION_ID;
586+
try {
587+
await handler.execute('codegraph_context', { task: 'find x' });
588+
} finally {
589+
delete process.env.CLAUDE_SESSION_ID;
590+
cg.close();
591+
}
592+
593+
// The victim file's contents must be untouched — the old writeFileSync
594+
// path would have followed the symlink and written an ISO timestamp here.
595+
expect(fs.readFileSync(victimFile, 'utf8')).toBe('SECRET-DO-NOT-OVERWRITE\n');
596+
597+
// And the marker path itself must still be the symlink we planted —
598+
// no fallback path that quietly unlinked + recreated it (which would
599+
// also work, but is a behavior we don't want to silently rely on).
600+
expect(fs.lstatSync(markerPath).isSymbolicLink()).toBe(true);
601+
});
602+
603+
it('writes the marker file with 0o600 perms on a clean path', async () => {
604+
// No symlink planted — happy path. Verifies the new openSync(mode: 0o600)
605+
// call is what actually lands on disk (regression guard for the perm
606+
// tightening that came with the O_NOFOLLOW fix).
607+
const cg = await CodeGraph.open(projectDir);
608+
const handler = new ToolHandler(cg);
609+
process.env.CLAUDE_SESSION_ID = SESSION_ID;
610+
try {
611+
await handler.execute('codegraph_context', { task: 'find x' });
612+
} finally {
613+
delete process.env.CLAUDE_SESSION_ID;
614+
cg.close();
615+
}
616+
617+
expect(fs.existsSync(markerPath)).toBe(true);
618+
// chmod's low 9 bits — strip the file-type bits for a clean compare.
619+
// Windows can't enforce 0o600 in the POSIX sense; skip the assertion
620+
// there since the underlying OS will normalize the mode anyway.
621+
if (process.platform !== 'win32') {
622+
const mode = fs.statSync(markerPath).mode & 0o777;
623+
expect(mode).toBe(0o600);
624+
}
625+
});
626+
});

src/mcp/tools.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@
77
import CodeGraph, { findNearestCodeGraphRoot } from '../index';
88
import type { Node, Edge, SearchResult, Subgraph, TaskContext, NodeKind } from '../types';
99
import { createHash } from 'crypto';
10-
import { writeFileSync, readFileSync, existsSync } from 'fs';
10+
import {
11+
constants as fsConstants,
12+
closeSync,
13+
existsSync,
14+
openSync,
15+
readFileSync,
16+
writeSync,
17+
} from 'fs';
1118
import { clamp, validatePathWithinRoot } from '../utils';
1219
import { tmpdir } from 'os';
1320
import { join } from 'path';
@@ -186,14 +193,36 @@ function numberSourceLines(slice: string, firstLineNumber: number): string {
186193
/**
187194
* Mark a Claude session as having consulted MCP tools.
188195
* This enables Grep/Glob/Bash commands that would otherwise be blocked.
196+
*
197+
* Why the explicit openSync + O_NOFOLLOW dance instead of plain writeFileSync:
198+
* tmpdir() is world-writable on Linux (mode 1777), so on a shared multi-user
199+
* machine any other local user can pre-create `codegraph-consulted-<hash>` as
200+
* a symlink pointing at a file the victim owns. The old `writeFileSync` would
201+
* happily follow that link and overwrite the target's contents with the ISO
202+
* timestamp string (CWE-59). The session-id hash provides the predictability
203+
* gate, but it's defense-in-depth: if a session id ever surfaces in logs,
204+
* argv, or telemetry the attack becomes trivial, and the right fix is to not
205+
* follow links from /tmp paths in the first place.
189206
*/
190207
function markSessionConsulted(sessionId: string): void {
191208
try {
192209
const hash = createHash('md5').update(sessionId).digest('hex').slice(0, 16);
193210
const markerPath = join(tmpdir(), `codegraph-consulted-${hash}`);
194-
writeFileSync(markerPath, new Date().toISOString(), 'utf8');
211+
// O_NOFOLLOW makes openSync throw ELOOP if markerPath is already a symlink.
212+
// O_CREAT + O_TRUNC keep the original "create-or-overwrite" semantics, and
213+
// mode 0o600 prevents readback by other local users (the marker payload is
214+
// benign, but narrowing the exposure costs nothing).
215+
const flags = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW;
216+
const fd = openSync(markerPath, flags, 0o600);
217+
try {
218+
writeSync(fd, new Date().toISOString());
219+
} finally {
220+
closeSync(fd);
221+
}
195222
} catch {
196-
// Silently fail - don't break MCP on marker write failure
223+
// Silently fail - don't break MCP on marker write failure. ELOOP from a
224+
// planted symlink lands here too, which is the intended behavior: refuse
225+
// to write rather than overwrite an attacker-chosen target.
197226
}
198227
}
199228

0 commit comments

Comments
 (0)