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
49 changes: 49 additions & 0 deletions src/filesystem/__tests__/unicode-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { setAllowedDirectories, validatePath } from '../lib.js';

describe('Unicode-equivalent filesystem paths', () => {
let testDirectory: string;

beforeEach(async () => {
testDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-unicode-paths-'));
setAllowedDirectories([testDirectory]);
});

afterEach(async () => {
setAllowedDirectories([]);
await fs.rm(testDirectory, { recursive: true, force: true });
});

it('resolves an existing decomposed path from a composed request', async () => {
const onDiskDirectory = 'de\u0301marche';
const onDiskFile = 're\u0301sume\u0301.txt';
await fs.mkdir(path.join(testDirectory, onDiskDirectory));
await fs.writeFile(path.join(testDirectory, onDiskDirectory, onDiskFile), 'content');

const resolved = await validatePath(path.join(testDirectory, 'd\u00e9marche', 'r\u00e9sum\u00e9.txt'));

expect(resolved).toBe(await fs.realpath(path.join(testDirectory, onDiskDirectory, onDiskFile)));
});

it('preserves a new basename after resolving a Unicode-equivalent parent', async () => {
const onDiskDirectory = 'de\u0301marche';
await fs.mkdir(path.join(testDirectory, onDiskDirectory));

const resolved = await validatePath(path.join(testDirectory, 'd\u00e9marche', 'new.txt'));

expect(resolved).toBe(path.join(await fs.realpath(path.join(testDirectory, onDiskDirectory)), 'new.txt'));
});

it('rejects ambiguous canonically equivalent entries', async () => {
const composed = 'caf\u00e9';
const decomposed = 'cafe\u0301';
await fs.mkdir(path.join(testDirectory, composed));
await fs.mkdir(path.join(testDirectory, decomposed));

await expect(validatePath(path.join(testDirectory, 'cafe\u0341', 'file.txt')))
.rejects.toThrow('Ambiguous Unicode path component');
});
});
53 changes: 45 additions & 8 deletions src/filesystem/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,46 @@ function resolveRelativePathAgainstAllowedDirectories(relativePath: string): str
}

// Security & Validation Functions
async function resolveUnicodeEquivalentPath(absolutePath: string): Promise<string> {
const allowedDirectory = [...allowedDirectories]
.sort((left, right) => right.length - left.length)
.find(directory => isPathWithinAllowedDirectories(normalizePath(absolutePath), [directory]));

if (!allowedDirectory) {
return absolutePath;
}

let currentPath = await fs.realpath(allowedDirectory);
const relativeParts = path.relative(allowedDirectory, absolutePath).split(path.sep).filter(Boolean);

for (let index = 0; index < relativeParts.length; index++) {
const requestedPart = relativeParts[index];
const entries = (await fs.readdir(currentPath)) ?? [];
const exactMatch = entries.find(entry => entry === requestedPart);
const equivalentMatches = exactMatch
? [exactMatch]
: entries.filter(entry => entry.normalize('NFC') === requestedPart.normalize('NFC'));

if (equivalentMatches.length > 1) {
throw new Error(`Ambiguous Unicode path component: ${requestedPart}`);
}

if (equivalentMatches.length === 0) {
if (index === relativeParts.length - 1) {
return path.join(currentPath, requestedPart);
}
throw new Error(`Parent directory does not exist: ${path.join(currentPath, requestedPart)}`);
}

currentPath = await fs.realpath(path.join(currentPath, equivalentMatches[0]));
if (!isPathWithinAllowedDirectories(normalizePath(currentPath), allowedDirectories)) {
throw new Error(`Access denied - symlink target outside allowed directories: ${currentPath} not in ${allowedDirectories.join(', ')}`);
}
}

return currentPath;
}

export async function validatePath(requestedPath: string): Promise<string> {
const expandedPath = expandHome(requestedPath);
const absolute = path.isAbsolute(expandedPath)
Expand Down Expand Up @@ -123,16 +163,13 @@ export async function validatePath(requestedPath: string): Promise<string> {
// Security: For new files that don't exist yet, verify parent directory
// This ensures we can't create files in unauthorized locations
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
const parentDir = path.dirname(absolute);
try {
const realParentPath = await fs.realpath(parentDir);
const normalizedParent = normalizePath(realParentPath);
if (!isPathWithinAllowedDirectories(normalizedParent, allowedDirectories)) {
throw new Error(`Access denied - parent directory outside allowed directories: ${realParentPath} not in ${allowedDirectories.join(', ')}`);
return await resolveUnicodeEquivalentPath(absolute);
} catch (resolutionError) {
if ((resolutionError as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Parent directory does not exist: ${path.dirname(absolute)}`);
}
return absolute;
} catch {
throw new Error(`Parent directory does not exist: ${parentDir}`);
throw resolutionError;
}
}
throw error;
Expand Down