Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion src/utils/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,16 @@ export async function getTempFilePath(filename: string) {
return filepath;
}

export async function resolveCanonicalPath(filePath: string): Promise<string> {
// 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<string> {
const absolutePath = path.resolve(filePath);
try {
// Get the true canonical path, resolving all symlinks.
Expand All @@ -31,6 +40,34 @@ export async function resolveCanonicalPath(filePath: string): Promise<string> {
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.
Expand Down
54 changes: 54 additions & 0 deletions tests/utils/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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});
}
});
});
Loading