fix(security): resolve symlinks in path validation to block out-of-root reads (#527) (#724)

* fix(security): resolve symlinks in path validation to block out-of-root reads (#527)

validatePathWithinRoot was purely lexical (path.resolve + startsWith), so an
in-repo symlink whose logical path is inside the project root but whose real
target escapes it passed validation — and both content-serving read sinks
(codegraph_node includeCode, codegraph_explore source) then readFileSync'd it,
leaking out-of-root file contents (e.g. ~/.ssh, /etc) to the agent.

Add a realpath layer: after the lexical check, resolve symlinks on both the
candidate path and the root and re-compare, rejecting anything whose real path
escapes the root. An in-root symlink is still allowed (no over-blocking).
Comparison is case-insensitive on Windows (NTFS + realpath casing). Not-yet-
existing paths (ENOENT) fall back to the lexical result so about-to-be-written
files still validate; other resolution errors reject.

Removes the dead, never-called isPathWithinRoot / isPathWithinRootReal helpers
(the latter a footgun — it returned true on realpath failure). Adds RED->GREEN
tests: in->out file/dir symlinks rejected, in->in allowed, ../ rejected, ENOENT
allowed, plus an end-to-end test proving getCode no longer serves an out-of-root
file reached through a dir symlink.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(changelog): note the #527 symlink path-escape fix

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-08 01:02:34 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 112e278b5c
commit 7fd8b4c185
3 changed files with 124 additions and 48 deletions
+46 -47
View File
@@ -66,21 +66,61 @@ export function isConfigLeafNode(node: { kind: string; language?: string }): boo
}
/**
* Validate that a resolved file path stays within the project root.
* Prevents path traversal attacks (e.g. node.filePath = "../../etc/passwd").
* Whether `child` is `parent` itself or sits underneath it. Case-insensitive on
* Windows — NTFS is case-insensitive, and realpathSync can hand back a different
* case than the lexical root, which would otherwise false-reject a valid file.
*/
function isWithinDir(child: string, parent: string): boolean {
let c = child;
let p = parent;
if (process.platform === 'win32') {
c = c.toLowerCase();
p = p.toLowerCase();
}
return c === p || c.startsWith(p + path.sep);
}
/**
* Validate that a file path stays within the project root, resolving symlinks.
*
* Two layers: a cheap lexical check that catches `../` traversal, then a
* realpath check that catches symlink escapes — an in-repo symlink whose
* logical path is inside the root but whose real target points outside it
* (issue #527). A symlink that stays within the root is still allowed, so
* legitimate in-tree symlinks keep working. Both content-serving read sinks
* (codegraph_node `includeCode`, codegraph_explore source) go through here, so
* this is the chokepoint that keeps out-of-root file contents from leaking.
*
* @param projectRoot - The project root directory
* @param filePath - The relative file path to validate
* @returns The resolved absolute path, or null if it escapes the root
* @param filePath - The (relative or absolute) file path to validate
* @returns The resolved absolute path (realpath when it exists), or null if it
* escapes the root
*/
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) {
// 1. Lexical containment — cheap, catches `../` traversal.
if (!isWithinDir(resolved, normalizedRoot)) {
return null;
}
// 2. Symlink-aware containment — resolve symlinks on both sides and re-check,
// so an in-repo symlink whose real target escapes the root is rejected.
try {
const realRoot = fs.realpathSync(normalizedRoot);
const realResolved = fs.realpathSync(resolved);
return isWithinDir(realResolved, realRoot) ? realResolved : null;
} catch (err) {
// ENOENT: the path doesn't exist yet (a file about to be written, or an
// index entry for a since-deleted file) — no symlink to follow, and the
// lexical check already passed, so allow the lexical path. Any other
// resolution failure (ELOOP, EACCES, …) is treated as unsafe → reject.
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return resolved;
}
return null;
}
return resolved;
}
/**
@@ -124,47 +164,6 @@ export function validateProjectPath(dirPath: string): string | null {
return null;
}
/**
* Check if a file path resolves to a location within the given root directory.
*
* Prevents path traversal attacks by ensuring the resolved absolute path
* starts with the resolved root path. Handles '..' sequences, symlink-like
* relative paths, and platform-specific separators.
*
* @param filePath - The path to check (can be relative or absolute)
* @param rootDir - The root directory that filePath must stay within
* @returns true if filePath resolves to a location within rootDir
*/
export function isPathWithinRoot(filePath: string, rootDir: string): boolean {
const resolvedPath = path.resolve(rootDir, filePath);
const resolvedRoot = path.resolve(rootDir);
return resolvedPath.startsWith(resolvedRoot + path.sep) || resolvedPath === resolvedRoot;
}
/**
* Like isPathWithinRoot but also resolves symlinks via fs.realpathSync.
*
* This catches symlink escapes where the logical path appears to be within
* root but the real path on disk points elsewhere. Falls back to logical
* path checking if realpath resolution fails (e.g. broken symlink).
*/
export function isPathWithinRootReal(filePath: string, rootDir: string): boolean {
// First do the cheap logical check
if (!isPathWithinRoot(filePath, rootDir)) {
return false;
}
// Then verify with realpath to catch symlink escapes
try {
const realPath = fs.realpathSync(path.resolve(rootDir, filePath));
const realRoot = fs.realpathSync(rootDir);
return realPath.startsWith(realRoot + path.sep) || realPath === realRoot;
} catch {
// If realpath fails (broken symlink, permissions), fall back to logical check
return true;
}
}
/**
* Safely parse JSON with a fallback value.
* Prevents crashes from corrupted database metadata.