diff --git a/src/utils/files.ts b/src/utils/files.ts index 74b371d26..8b91eba56 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -15,7 +15,16 @@ export async function getTempFilePath(filename: string) { return filepath; } -export async function resolveCanonicalPath(filePath: string): Promise { +// Upper bound on the number of nested symlinks resolved for a not-yet-existing +// target. Mirrors the kernel's SYMLOOP_MAX and prevents an unbounded loop when +// dangling symlinks form a cycle (fs.realpath() would report such a cycle as +// ELOOP, but it never reaches those links because their targets do not exist). +const MAX_SYMLINK_DEPTH = 40; + +export async function resolveCanonicalPath( + filePath: string, + symlinkDepth = 0, +): Promise { const absolutePath = path.resolve(filePath); try { // Get the true canonical path, resolving all symlinks. @@ -31,6 +40,34 @@ export async function resolveCanonicalPath(filePath: string): Promise { let current = absolutePath; const missingSegments: string[] = []; while (true) { + // A dangling symlink (one whose target does not exist yet) is reported + // as ENOENT by fs.realpath(), so the walk below would otherwise treat + // the link's own name as an ordinary missing segment and re-append it + // verbatim. That yields an in-tree path even though a follow-the-link + // write lands wherever the link points. Resolve such a link explicitly + // so the returned canonical path reflects the real write destination + // and MCP roots validation cannot be bypassed by a symlink placed + // inside a configured root. + let linkStat; + try { + linkStat = await fs.lstat(current); + } catch { + linkStat = undefined; + } + if (linkStat?.isSymbolicLink()) { + if (symlinkDepth >= MAX_SYMLINK_DEPTH) { + throw err; + } + const linkTarget = path.resolve( + path.dirname(current), + await fs.readlink(current), + ); + return await resolveCanonicalPath( + path.join(linkTarget, ...missingSegments), + symlinkDepth + 1, + ); + } + const parent = path.dirname(current); if (parent === current) { // Reached root directory but still couldn't resolve anything. diff --git a/tests/utils/files.test.ts b/tests/utils/files.test.ts index 2cfc5e401..fade73a03 100644 --- a/tests/utils/files.test.ts +++ b/tests/utils/files.test.ts @@ -92,4 +92,58 @@ describe('resolveCanonicalPath', () => { await fs.rm(tmpDir, {recursive: true, force: true}); } }); + + it('should resolve a dangling symlink to its target rather than its own path', async () => { + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'resolve-canonical-test-'), + ); + try { + const insideDir = path.join(tmpDir, 'inside'); + const outsideDir = path.join(tmpDir, 'outside'); + await fs.mkdir(insideDir); + await fs.mkdir(outsideDir); + + // A symlink that lives inside `insideDir` but points at a not-yet-created + // file under `outsideDir`. fs.realpath() reports ENOENT for such a link, + // so it must be resolved explicitly to its target. + const danglingLink = path.join(insideDir, 'report.txt'); + const linkTarget = path.join(outsideDir, 'pwned.txt'); + await fs.symlink(linkTarget, danglingLink); + + const resolved = await resolveCanonicalPath(danglingLink); + assert.strictEqual( + resolved, + path.join(await fs.realpath(outsideDir), 'pwned.txt'), + ); + } finally { + await fs.rm(tmpDir, {recursive: true, force: true}); + } + }); + + it('should resolve a not-yet-existing file through a dangling directory symlink', async () => { + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'resolve-canonical-test-'), + ); + try { + const insideDir = path.join(tmpDir, 'inside'); + await fs.mkdir(insideDir); + + // `escape` is a directory symlink inside `insideDir` whose target does + // not exist yet. Both the target directory and the final file are + // missing, so the walk must still resolve the intermediate symlink. + const escapeLink = path.join(insideDir, 'escape'); + const escapeTarget = path.join(tmpDir, 'outside-dir'); + await fs.symlink(escapeTarget, escapeLink, 'dir'); + + const filePath = path.join(escapeLink, 'out.txt'); + + const resolved = await resolveCanonicalPath(filePath); + assert.strictEqual( + resolved, + path.join(await fs.realpath(tmpDir), 'outside-dir', 'out.txt'), + ); + } finally { + await fs.rm(tmpDir, {recursive: true, force: true}); + } + }); });