fix(extraction): index files reached through in-root symlinks that point outside the repo (#935) (#956)
The directory walk deliberately follows an in-root symlink whose target lives outside the repo root (the standard Dota custom-game layout, where `game/` and `content/` link into the SDK tree) and enumerates the files under it. But the read path then rejected every one of them via the strict symlink-escape guard, logging `Path traversal blocked in batch reader` and indexing nothing — discovery and the reader disagreed. Add an opt-in `allowSymlinkEscape` to validatePathWithinRoot that waives only the realpath-escape rejection (the lexical `../` guard still applies) and pass it at the three indexing read sites (batch reader, indexFile, indexFileWithContent). The content-serving sinks (ContextBuilder, MCP tools) keep the strict guard, so this stays inside the #527 model: indexing now follows the symlink, getCode still refuses to serve out-of-root contents. 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
d1121e46f0
commit
6459ead6aa
@@ -1245,7 +1245,10 @@ export class ExtractionOrchestrator {
|
||||
const fileContents = await Promise.all(
|
||||
batch.map(async (fp) => {
|
||||
try {
|
||||
const fullPath = validatePathWithinRoot(this.rootDir, fp);
|
||||
// Indexing read: follow in-root symlinks the directory walk already
|
||||
// descended into (the `../` guard still applies) so files reached
|
||||
// via an in-root symlink-to-outside still index (#935).
|
||||
const fullPath = validatePathWithinRoot(this.rootDir, fp, { allowSymlinkEscape: true });
|
||||
if (!fullPath) {
|
||||
logWarn('Path traversal blocked in batch reader', { filePath: fp });
|
||||
return { filePath: fp, content: null as string | null, stats: null as fs.Stats | null, error: new Error('Path traversal blocked') };
|
||||
@@ -1551,7 +1554,8 @@ export class ExtractionOrchestrator {
|
||||
* Index a single file
|
||||
*/
|
||||
async indexFile(relativePath: string): Promise<ExtractionResult> {
|
||||
const fullPath = validatePathWithinRoot(this.rootDir, relativePath);
|
||||
// Indexing read: follow in-root symlinks (the `../` guard still applies), #935.
|
||||
const fullPath = validatePathWithinRoot(this.rootDir, relativePath, { allowSymlinkEscape: true });
|
||||
|
||||
if (!fullPath) {
|
||||
return {
|
||||
@@ -1598,8 +1602,8 @@ export class ExtractionOrchestrator {
|
||||
content: string,
|
||||
stats: fs.Stats
|
||||
): Promise<ExtractionResult> {
|
||||
// Prevent path traversal
|
||||
const fullPath = validatePathWithinRoot(this.rootDir, relativePath);
|
||||
// Prevent `../` traversal; follow in-root symlinks like the directory walk (#935).
|
||||
const fullPath = validatePathWithinRoot(this.rootDir, relativePath, { allowSymlinkEscape: true });
|
||||
if (!fullPath) {
|
||||
logWarn('Path traversal blocked in indexFileWithContent', { relativePath });
|
||||
return {
|
||||
|
||||
+24
-2
@@ -91,25 +91,47 @@ function isWithinDir(child: string, parent: string): boolean {
|
||||
* (codegraph_node `includeCode`, codegraph_explore source) go through here, so
|
||||
* this is the chokepoint that keeps out-of-root file contents from leaking.
|
||||
*
|
||||
* `allowSymlinkEscape` waives **only** the realpath-escape rejection (the
|
||||
* lexical `../` guard still applies) for the INDEXING read path. The directory
|
||||
* walk deliberately descends into in-root symlinks whose targets live outside
|
||||
* the root (e.g. a `game/` symlink in a Dota custom-game tree, #935); discovery
|
||||
* and the reader must agree, or every file the walk enumerated fails to index.
|
||||
* Indexing only reads paths it just discovered, into a local index — it never
|
||||
* serves them to an agent, so this does not widen the #527 leak surface. The
|
||||
* content-serving sinks must never pass this flag.
|
||||
*
|
||||
* @param projectRoot - The project root directory
|
||||
* @param filePath - The (relative or absolute) file path to validate
|
||||
* @param options.allowSymlinkEscape - Follow in-root symlinks out of the root
|
||||
* (indexing read path only); defaults to the strict, leak-safe behavior.
|
||||
* @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 {
|
||||
export function validatePathWithinRoot(
|
||||
projectRoot: string,
|
||||
filePath: string,
|
||||
options?: { allowSymlinkEscape?: boolean }
|
||||
): string | null {
|
||||
const resolved = path.resolve(projectRoot, filePath);
|
||||
const normalizedRoot = path.resolve(projectRoot);
|
||||
|
||||
// 1. Lexical containment — cheap, catches `../` traversal.
|
||||
// 1. Lexical containment — cheap, catches `../` traversal. Applies even on
|
||||
// the indexing read path: a crafted `../` escape is still rejected.
|
||||
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.
|
||||
// The indexing read path (allowSymlinkEscape) skips only this rejection so
|
||||
// it stays consistent with the directory walk, which already followed the
|
||||
// in-root symlink to enumerate these files (#935).
|
||||
try {
|
||||
const realRoot = fs.realpathSync(normalizedRoot);
|
||||
const realResolved = fs.realpathSync(resolved);
|
||||
if (options?.allowSymlinkEscape) {
|
||||
return realResolved;
|
||||
}
|
||||
return isWithinDir(realResolved, realRoot) ? realResolved : null;
|
||||
} catch (err) {
|
||||
// ENOENT: the path doesn't exist yet (a file about to be written, or an
|
||||
|
||||
Reference in New Issue
Block a user