* 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:
co-authored by
Claude Opus 4.8
parent
112e278b5c
commit
7fd8b4c185
+46
-47
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user