diff --git a/src/filesystem/__tests__/list-directory-with-sizes.symlink.test.ts b/src/filesystem/__tests__/list-directory-with-sizes.symlink.test.ts new file mode 100644 index 0000000000..032e9af201 --- /dev/null +++ b/src/filesystem/__tests__/list-directory-with-sizes.symlink.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +// Regression test: list_directory_with_sizes must not follow a symlink that points +// outside the allowed directories (it previously used fs.stat and leaked the target's +// size/mtime). Place under src/filesystem/__tests__/ and run after `npm run build`. +describe('list_directory_with_sizes symlink confinement', () => { + let client: Client; + let transport: StdioClientTransport; + let allowedDir: string; + let outsideDir: string; + + beforeEach(async () => { + allowedDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-fs-allowed-'))); + outsideDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-fs-outside-'))); + + // A distinctively large file OUTSIDE the sandbox (1 MiB -> "1.00 MB" via formatSize). + await fs.writeFile(path.join(outsideDir, 'secret.bin'), Buffer.alloc(1024 * 1024)); + // A symlink INSIDE the allowed dir pointing at it. + await fs.symlink(path.join(outsideDir, 'secret.bin'), path.join(allowedDir, 'leak_link')); + await fs.writeFile(path.join(allowedDir, 'normal.txt'), 'hello'); + + const serverPath = path.resolve(__dirname, '../dist/index.js'); + transport = new StdioClientTransport({ command: 'node', args: [serverPath, allowedDir] }); + client = new Client({ name: 'list-sizes-symlink-test', version: '1.0.0' }, { capabilities: {} }); + await client.connect(transport); + }); + + afterEach(async () => { + await client?.close(); + await fs.rm(allowedDir, { recursive: true, force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + }); + + it('does not report the out-of-sandbox target size for a symlink', async () => { + const result = await client.callTool({ + name: 'list_directory_with_sizes', + arguments: { path: allowedDir }, + }); + const text = (result.structuredContent as { content: string }).content; + + // The link is still listed... + expect(text).toContain('leak_link'); + // ...but its target's size must not leak. With fs.stat it showed "1.00 MB"; + // with fs.lstat it shows the link's own (tiny) size. + expect(text).not.toContain('MB'); + }); +}); diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 51ac523a66..b3006d25f1 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -489,7 +489,12 @@ server.registerTool( entries.map(async (entry) => { const entryPath = path.join(validPath, entry.name); try { - const stats = await fs.stat(entryPath); + // Use lstat so a symlink is described by its own metadata rather than its + // target's. fs.stat follows symlinks, which would report the size/mtime of a + // file outside the allowed directories when an allowed directory contains a + // symlink pointing out of the sandbox. This matches list_directory and + // directory_tree, which classify entries from the (non-following) Dirent. + const stats = await fs.lstat(entryPath); return { name: entry.name, isDirectory: entry.isDirectory(),