fix(filesystem): resolve Unicode-equivalent paths (#4638)

Signed-off-by: KXH <shepherdlaurie238@gmail.com>
This commit is contained in:
KXH 2026-08-29 01:01:08 +08:00 • committed by GitHub
commit cf05b37694
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 94 additions and 8 deletions

View file

@ -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');
});
});

View file

@ -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)
@ -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;