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
@@ -232,6 +232,26 @@ describe('Symlink escape prevention (#527)', () => {
|
||||
expect(validatePathWithinRoot(root, 'src/inlink.ts')).not.toBeNull();
|
||||
});
|
||||
|
||||
// The INDEXING read path opts into following in-root symlinks the directory
|
||||
// walk already descended into — discovery and the reader must agree, or files
|
||||
// reached via an in-root symlink-to-outside fail to index (#935). The lexical
|
||||
// `../` guard is NOT waived, and content-serving sinks never pass the flag.
|
||||
it('allowSymlinkEscape follows an in-repo symlink to an out-of-root FILE (indexing read)', () => {
|
||||
if (!link(path.join(root, 'escape'), path.join(outside, 'pkg', 'secret.txt'))) return;
|
||||
expect(validatePathWithinRoot(root, 'escape', { allowSymlinkEscape: true })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('allowSymlinkEscape follows a path through an in-repo out-of-root DIR symlink (indexing read)', () => {
|
||||
if (!link(path.join(root, 'escapedir'), path.join(outside, 'pkg'))) return;
|
||||
expect(validatePathWithinRoot(root, 'escapedir/secret.txt', { allowSymlinkEscape: true })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('allowSymlinkEscape STILL rejects a lexical ../ traversal (guard not waived)', () => {
|
||||
expect(
|
||||
validatePathWithinRoot(root, `../${path.basename(outside)}/pkg/secret.txt`, { allowSymlinkEscape: true })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('end-to-end: getCode never serves an out-of-root file reached via a dir symlink', async () => {
|
||||
fs.writeFileSync(path.join(outside, 'pkg', 'leak.ts'),
|
||||
'export function leaked() { return "LEAKED-ZZZ-9"; }\n');
|
||||
@@ -250,6 +270,32 @@ describe('Symlink escape prevention (#527)', () => {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('end-to-end (#935): indexes source reached through an in-root dir symlink to outside', async () => {
|
||||
// The Dota custom-game layout symlinks `game/` and `content/` into an SDK
|
||||
// tree outside the repo. Before #935 the batch reader's strict symlink-escape
|
||||
// guard blocked every file under them, so nothing indexed — even though the
|
||||
// directory walk deliberately followed the symlink to enumerate them. The
|
||||
// reader now agrees with discovery: the file indexes.
|
||||
fs.writeFileSync(path.join(outside, 'pkg', 'vendored.ts'),
|
||||
'export function vendoredHelper() { return "LEAKED-ZZZ-9"; }\n');
|
||||
if (!link(path.join(root, 'game'), path.join(outside, 'pkg'))) return;
|
||||
|
||||
const cg = CodeGraph.initSync(root, { config: { include: ['**/*.ts'], exclude: [] } });
|
||||
try {
|
||||
await cg.indexAll();
|
||||
// The symlinked-in file is now part of the graph...
|
||||
const names = cg.getNodesByKind('function').map((n) => n.name);
|
||||
expect(names).toContain('vendoredHelper');
|
||||
// ...but its out-of-root contents are STILL never served (the #527 sink
|
||||
// stays strict — indexing relaxes only the read path, not getCode).
|
||||
for (const n of cg.getNodesByKind('function')) {
|
||||
expect((await cg.getCode(n.id)) ?? '').not.toContain('LEAKED-ZZZ-9');
|
||||
}
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateProjectPath — sensitive directory blocking', () => {
|
||||
|
||||
Reference in New Issue
Block a user