Skip to content

Commit f20dee9

Browse files
committed
feat(filesystem): add --follow-symlinks and --symlink-depth options
Closes modelcontextprotocol#3457 - Add followSymlinks and symlinkMaxDepth policy to lib.ts - Modify validatePath() to use lstat + hop-by-hop resolution when enabled - Circular symlink detection via visited Set - Filter -- flags from dirArgs before building allowedDirectories - 13 new test cases covering all symlink scenarios - Default behavior unchanged (followSymlinks: false)
1 parent f424458 commit f20dee9

4 files changed

Lines changed: 529 additions & 14 deletions

File tree

‎src/filesystem/README.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ Specify Allowed directories when starting the server:
2121
mcp-server-filesystem /path/to/dir1 /path/to/dir2
2222
```
2323

24+
#### Optional Flags
25+
26+
- `--follow-symlinks` - Allow symlinks to point to targets outside allowed directories (default: false)
27+
- `--symlink-depth=N` - Maximum number of symlink hops outside allowed directories before blocking (default: 1)
28+
29+
Example with symlink following enabled:
30+
```bash
31+
mcp-server-filesystem /home/user/docs --follow-symlinks --symlink-depth=2
32+
```
33+
34+
With these flags:
35+
- Symlinks pointing to files outside allowed directories are allowed up to the specified depth
36+
- Symlink chains that exceed the max depth will be blocked with a clear error message
37+
- This is useful when you need to access symlinks that point to external storage or shared directories
38+
2439
### Method 2: MCP Roots (Recommended)
2540
MCP clients that support [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) can dynamically update the Allowed directories.
2641

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import * as path from 'path';
3+
import * as fs from 'fs/promises';
4+
import * as os from 'os';
5+
import { validatePath, setAllowedDirectories, setSymlinkPolicy, getSymlinkPolicy } from '../lib.js';
6+
import { normalizePath } from '../path-utils.js';
7+
8+
/**
9+
* Check if the current environment supports symlink creation
10+
*/
11+
async function checkSymlinkSupport(): Promise<boolean> {
12+
const testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'symlink-test-'));
13+
try {
14+
const targetFile = path.join(testDir, 'target.txt');
15+
const linkFile = path.join(testDir, 'link.txt');
16+
17+
await fs.writeFile(targetFile, 'test');
18+
await fs.symlink(targetFile, linkFile);
19+
20+
return true;
21+
} catch (error) {
22+
if ((error as NodeJS.ErrnoException).code === 'EPERM') {
23+
return false;
24+
}
25+
throw error;
26+
} finally {
27+
await fs.rm(testDir, { recursive: true, force: true });
28+
}
29+
}
30+
31+
let symlinkSupported: boolean | null = null;
32+
33+
async function getSymlinkSupport(): Promise<boolean> {
34+
if (symlinkSupported === null) {
35+
symlinkSupported = await checkSymlinkSupport();
36+
if (!symlinkSupported) {
37+
console.log('\n⚠️ Symlink tests will be skipped - symlink creation not supported in this environment');
38+
}
39+
}
40+
return symlinkSupported;
41+
}
42+
43+
/**
44+
* Helper to resolve allowed directories similar to index.ts logic
45+
* Handles macOS /var -> /private/var symlinks
46+
*/
47+
async function resolveAllowedDirectories(dir: string): Promise<string[]> {
48+
const absolute = path.resolve(dir);
49+
const normalizedOriginal = normalizePath(absolute);
50+
try {
51+
const resolved = await fs.realpath(absolute);
52+
const normalizedResolved = normalizePath(resolved);
53+
if (normalizedOriginal !== normalizedResolved) {
54+
return [normalizedOriginal, normalizedResolved];
55+
}
56+
return [normalizedResolved];
57+
} catch {
58+
return [normalizedOriginal];
59+
}
60+
}
61+
62+
describe('Symlink Policy', () => {
63+
let testDir: string;
64+
let allowedDir: string;
65+
let forbiddenDir: string;
66+
67+
beforeEach(async () => {
68+
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'symlink-policy-test-'));
69+
allowedDir = path.join(testDir, 'allowed');
70+
forbiddenDir = path.join(testDir, 'forbidden');
71+
72+
await fs.mkdir(allowedDir, { recursive: true });
73+
await fs.mkdir(forbiddenDir, { recursive: true });
74+
75+
// Set allowed directories using helper that handles macOS symlinks
76+
const resolvedAllowedDirs = await resolveAllowedDirectories(allowedDir);
77+
setAllowedDirectories(resolvedAllowedDirs);
78+
79+
// Reset symlink policy to default
80+
setSymlinkPolicy({ follow: false, maxDepth: 1 });
81+
});
82+
83+
afterEach(async () => {
84+
await fs.rm(testDir, { recursive: true, force: true });
85+
});
86+
87+
describe('Default behavior (followSymlinks: false)', () => {
88+
it('blocks symlink pointing outside allowed directories by default', async () => {
89+
const symlinkSupported = await getSymlinkSupport();
90+
if (!symlinkSupported) {
91+
console.log(' ⏭️ Skipping - symlinks not supported');
92+
return;
93+
}
94+
95+
// Create target file outside allowed directory
96+
const targetFile = path.join(forbiddenDir, 'target.txt');
97+
await fs.writeFile(targetFile, 'content');
98+
99+
// Create symlink inside allowed directory pointing to forbidden file
100+
const linkPath = path.join(allowedDir, 'link.txt');
101+
await fs.symlink(targetFile, linkPath);
102+
103+
// Default behavior should block the symlink
104+
await expect(validatePath(linkPath)).rejects.toThrow(/symlink target outside allowed directories/);
105+
});
106+
107+
it('allows symlink within allowed directories', async () => {
108+
const symlinkSupported = await getSymlinkSupport();
109+
if (!symlinkSupported) {
110+
console.log(' ⏭️ Skipping - symlinks not supported');
111+
return;
112+
}
113+
114+
// Create target file inside allowed directory
115+
const targetFile = path.join(allowedDir, 'target.txt');
116+
await fs.writeFile(targetFile, 'content');
117+
118+
// Create symlink to file within allowed directory
119+
const linkPath = path.join(allowedDir, 'link.txt');
120+
await fs.symlink(targetFile, linkPath);
121+
122+
// Should pass validation (path should resolve correctly, can be /private/ version on macOS)
123+
const result = await validatePath(linkPath);
124+
expect(result).toBeTruthy();
125+
});
126+
127+
it('blocks regular files outside allowed directories', async () => {
128+
const outsideFile = path.join(forbiddenDir, 'file.txt');
129+
await fs.writeFile(outsideFile, 'content');
130+
131+
// Should throw because path is outside allowed directories
132+
await expect(validatePath(outsideFile)).rejects.toThrow(/path outside allowed directories/);
133+
});
134+
});
135+
136+
describe('With followSymlinks: true', () => {
137+
beforeEach(() => {
138+
setSymlinkPolicy({ follow: true, maxDepth: 1 });
139+
});
140+
141+
it('allows symlink outside allowed dir with depth 1', async () => {
142+
const symlinkSupported = await getSymlinkSupport();
143+
if (!symlinkSupported) {
144+
console.log(' ⏭️ Skipping - symlinks not supported');
145+
return;
146+
}
147+
148+
const targetFile = path.join(forbiddenDir, 'target.txt');
149+
await fs.writeFile(targetFile, 'content');
150+
151+
const linkPath = path.join(allowedDir, 'link.txt');
152+
await fs.symlink(targetFile, linkPath);
153+
154+
// Should pass with default maxDepth of 1
155+
const result = await validatePath(linkPath);
156+
expect(result).toBe(targetFile);
157+
});
158+
159+
it('allows symlink pointing to file within allowed directory', async () => {
160+
const symlinkSupported = await getSymlinkSupport();
161+
if (!symlinkSupported) {
162+
console.log(' ⏭️ Skipping - symlinks not supported');
163+
return;
164+
}
165+
166+
const targetFile = path.join(allowedDir, 'target.txt');
167+
await fs.writeFile(targetFile, 'content');
168+
169+
const linkPath = path.join(allowedDir, 'link.txt');
170+
await fs.symlink(targetFile, linkPath);
171+
172+
const result = await validatePath(linkPath);
173+
expect(result).toBeTruthy();
174+
});
175+
176+
it('blocks symlink chain exceeding max depth', async () => {
177+
const symlinkSupported = await getSymlinkSupport();
178+
if (!symlinkSupported) {
179+
console.log(' ⏭️ Skipping - symlinks not supported');
180+
return;
181+
}
182+
183+
// Set max depth to 1
184+
setSymlinkPolicy({ follow: true, maxDepth: 1 });
185+
186+
// Create a chain: allowed/link -> forbidden/link1 -> forbidden/link2 -> target
187+
const targetFile = path.join(forbiddenDir, 'target.txt');
188+
await fs.writeFile(targetFile, 'content');
189+
190+
const link1 = path.join(forbiddenDir, 'link1');
191+
const link2 = path.join(forbiddenDir, 'link2');
192+
const finalLink = path.join(allowedDir, 'final-link');
193+
194+
await fs.symlink(targetFile, link1);
195+
await fs.symlink(link1, link2);
196+
await fs.symlink(link2, finalLink);
197+
198+
// Chain has 2 hops outside allowed dirs, exceeds maxDepth of 1
199+
await expect(validatePath(finalLink)).rejects.toThrow(/exceeded max depth of 1/);
200+
});
201+
202+
it('allows symlink chain within max depth', async () => {
203+
const symlinkSupported = await getSymlinkSupport();
204+
if (!symlinkSupported) {
205+
console.log(' ⏭️ Skipping - symlinks not supported');
206+
return;
207+
}
208+
209+
// Set max depth to 3 (to account for the chain)
210+
setSymlinkPolicy({ follow: true, maxDepth: 3 });
211+
212+
const targetFile = path.join(forbiddenDir, 'target.txt');
213+
await fs.writeFile(targetFile, 'content');
214+
215+
const link1 = path.join(forbiddenDir, 'link1');
216+
const link2 = path.join(forbiddenDir, 'link2');
217+
const finalLink = path.join(allowedDir, 'final-link');
218+
219+
await fs.symlink(targetFile, link1);
220+
await fs.symlink(link1, link2);
221+
await fs.symlink(link2, finalLink);
222+
223+
// Chain has 2 hops outside allowed dirs, within maxDepth of 3
224+
const result = await validatePath(finalLink);
225+
expect(result).toBeTruthy();
226+
});
227+
228+
it('allows symlink that loops back into allowed dir after outside hop', async () => {
229+
const symlinkSupported = await getSymlinkSupport();
230+
if (!symlinkSupported) {
231+
console.log(' ⏭️ Skipping - symlinks not supported');
232+
return;
233+
}
234+
235+
// Create: allowed/link1 -> forbidden/link2 -> allowed/target
236+
const targetFile = path.join(allowedDir, 'target.txt');
237+
await fs.writeFile(targetFile, 'content');
238+
239+
const outsideLink = path.join(forbiddenDir, 'outside-link');
240+
const finalLink = path.join(allowedDir, 'final-link');
241+
242+
await fs.symlink(targetFile, outsideLink);
243+
await fs.symlink(outsideLink, finalLink);
244+
245+
// Chain: hop 1 (outsideLink) is outside allowed dir = 1 hop
246+
// hop 2 (targetFile) is inside allowed dir = 0 additional hops outside
247+
// Total outside hops = 1, within maxDepth of 1
248+
const result = await validatePath(finalLink);
249+
expect(result).toBeTruthy();
250+
});
251+
252+
it('blocks symlink that loops back after exceeding depth', async () => {
253+
const symlinkSupported = await getSymlinkSupport();
254+
if (!symlinkSupported) {
255+
console.log(' ⏭️ Skipping - symlinks not supported');
256+
return;
257+
}
258+
259+
// Set max depth to 1
260+
setSymlinkPolicy({ follow: true, maxDepth: 1 });
261+
262+
// Create chain: allowed/link1 -> forbidden/link2 -> forbidden/link3 -> allowed/target
263+
const targetFile = path.join(allowedDir, 'target.txt');
264+
await fs.writeFile(targetFile, 'content');
265+
266+
const link1 = path.join(forbiddenDir, 'link1');
267+
const link2 = path.join(forbiddenDir, 'link2');
268+
const finalLink = path.join(allowedDir, 'final-link');
269+
270+
await fs.symlink(targetFile, link1);
271+
await fs.symlink(link1, link2);
272+
await fs.symlink(link2, finalLink);
273+
274+
// Chain has 2 hops outside before reaching final target
275+
await expect(validatePath(finalLink)).rejects.toThrow(/exceeded max depth of 1/);
276+
});
277+
278+
it('detects circular symlinks and throws', async () => {
279+
const symlinkSupported = await getSymlinkSupport();
280+
if (!symlinkSupported) {
281+
console.log(' ⏭️ Skipping - symlinks not supported');
282+
return;
283+
}
284+
285+
// Add test dir to allowed directories for this test
286+
const resolvedDirs = await resolveAllowedDirectories(testDir);
287+
setAllowedDirectories(resolvedDirs);
288+
289+
setSymlinkPolicy({ follow: true, maxDepth: 5 });
290+
291+
// Create circular symlinks
292+
const linkA = path.join(testDir, 'link-a');
293+
const linkB = path.join(testDir, 'link-b');
294+
295+
await fs.symlink(linkB, linkA);
296+
await fs.symlink(linkA, linkB);
297+
298+
// Should detect circular symlink
299+
await expect(validatePath(linkA)).rejects.toThrow(/circular symlink detected/);
300+
});
301+
302+
it('handles non-existent paths with symlink policy', async () => {
303+
setSymlinkPolicy({ follow: true, maxDepth: 1 });
304+
305+
const newFilePath = path.join(allowedDir, 'newfile.txt');
306+
307+
// Should return the path without error for new files in existing directory
308+
const result = await validatePath(newFilePath);
309+
expect(result).toBe(newFilePath);
310+
});
311+
});
312+
313+
describe('getSymlinkPolicy', () => {
314+
it('returns current symlink policy', () => {
315+
setSymlinkPolicy({ follow: true, maxDepth: 5 });
316+
317+
const policy = getSymlinkPolicy();
318+
expect(policy.follow).toBe(true);
319+
expect(policy.maxDepth).toBe(5);
320+
});
321+
322+
it('returns default policy when not set', () => {
323+
// Reset to defaults
324+
setSymlinkPolicy({ follow: false, maxDepth: 1 });
325+
326+
const policy = getSymlinkPolicy();
327+
expect(policy.follow).toBe(false);
328+
expect(policy.maxDepth).toBe(1);
329+
});
330+
});
331+
});

0 commit comments

Comments
 (0)