Bug: validatePathWithinRoot allows reading files outside project root via symlinks
Reproduction
#!/bin/bash
# End-to-end: symlink inside project → file outside root → leaked via MCP
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
mkdir -p "$TMPDIR/project/src" "$TMPDIR/secrets"
# Sensitive file OUTSIDE the project root
echo 'DATABASE_URL="postgres://admin:password@prod-db:5432"' > "$TMPDIR/secrets/config.ts"
# Symlink INSIDE the project pointing outside
ln -s "$TMPDIR/secrets/config.ts" "$TMPDIR/project/src/database.config.ts"
# A legit source file so indexing has something to scan
echo 'export function hello() { return "world"; }' > "$TMPDIR/project/src/index.ts"
cd "$TMPDIR/project"
codegraph init -i # indexes the project (follows symlink, stores "src/database.config.ts")
# Later, an MCP agent calls codegraph_explore or codegraph_node with includeCode=true.
# validatePathWithinRoot resolves "src/database.config.ts" to an in-root path,
# but readFileSync follows the symlink to $TMPDIR/secrets/config.ts.
Run with: bash poc.sh
Expected: Symlinks pointing outside the project root should be rejected — their content should not be readable through MCP tools.
Actual: The symlinked file is indexed and its contents are served to MCP agents, leaking files outside the project root.
Environment
- Version: 02935d7 (latest main as of 2025-05-28)
- Node: v25.9.0
- OS: macOS (arm64)
Root Cause
validatePathWithinRoot (src/utils.ts:57) uses only path.resolve to check containment, which resolves .. lexically but does not resolve symlinks. A symlink inside the project pointing to a file outside the root passes the check because the logical path appears within the root.
The codebase already has isPathWithinRootReal (src/utils.ts:97) that properly calls fs.realpathSync to catch this exact class of attack — but it is never used in the file-read paths of the MCP tools or extraction pipeline.
Two places where this matters:
- MCP tool file reads (
src/mcp/tools.ts:1403, 1432, 1743): readFileSync follows the symlink after validatePathWithinRoot approves the logical path.
- Extraction/indexing (
src/extraction/index.ts): The walk() function does resolve symlinks with realpathSync, but stores the relative path (path.relative(rootDir, fullPath)) using the symlink's name, not the real target. Later reads of stored paths follow the symlink.
Suggested Fix
Replace validatePathWithinRoot calls in file-read paths with isPathWithinRootReal (or add fs.realpathSync to validatePathWithinRoot):
// src/utils.ts — add realpath check to validatePathWithinRoot
export function validatePathWithinRoot(projectRoot: string, filePath: string): string | null {
const resolved = path.resolve(projectRoot, filePath);
const normalizedRoot = path.resolve(projectRoot);
if (!resolved.startsWith(normalizedRoot + path.sep) && resolved !== normalizedRoot) {
return null;
}
// Resolve symlinks to prevent escapes
try {
const realPath = fs.realpathSync(resolved);
const realRoot = fs.realpathSync(normalizedRoot);
if (!realPath.startsWith(realRoot + path.sep) && realPath !== realRoot) {
return null;
}
return realPath;
} catch {
// File doesn't exist yet (e.g., during write) — logical check already passed
return resolved;
}
}
This preserves backward compatibility while closing the symlink escape. The repo's existing isPathWithinRootReal shows the maintainers are aware of this pattern — it just needs to be applied consistently.
Bug:
validatePathWithinRootallows reading files outside project root via symlinksReproduction
Run with:
bash poc.shExpected: Symlinks pointing outside the project root should be rejected — their content should not be readable through MCP tools.
Actual: The symlinked file is indexed and its contents are served to MCP agents, leaking files outside the project root.
Environment
Root Cause
validatePathWithinRoot(src/utils.ts:57) uses onlypath.resolveto check containment, which resolves..lexically but does not resolve symlinks. A symlink inside the project pointing to a file outside the root passes the check because the logical path appears within the root.The codebase already has
isPathWithinRootReal(src/utils.ts:97) that properly callsfs.realpathSyncto catch this exact class of attack — but it is never used in the file-read paths of the MCP tools or extraction pipeline.Two places where this matters:
src/mcp/tools.ts:1403, 1432, 1743):readFileSyncfollows the symlink aftervalidatePathWithinRootapproves the logical path.src/extraction/index.ts): Thewalk()function does resolve symlinks withrealpathSync, but stores the relative path (path.relative(rootDir, fullPath)) using the symlink's name, not the real target. Later reads of stored paths follow the symlink.Suggested Fix
Replace
validatePathWithinRootcalls in file-read paths withisPathWithinRootReal(or addfs.realpathSynctovalidatePathWithinRoot):This preserves backward compatibility while closing the symlink escape. The repo's existing
isPathWithinRootRealshows the maintainers are aware of this pattern — it just needs to be applied consistently.