mirror of
https://github.com/modelcontextprotocol/servers.git
synced 2026-04-25 23:35:19 +02:00
Merge branch 'main' into fix_search_files
Resolved merge conflicts while preserving glob pattern functionality for search_files. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,8 +9,58 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio
|
||||
- Move files/directories
|
||||
- Search files
|
||||
- Get file metadata
|
||||
- Dynamic directory access control via [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots)
|
||||
|
||||
## Directory Access Control
|
||||
|
||||
The server uses a flexible directory access control system. Directories can be specified via command-line arguments or dynamically via [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots).
|
||||
|
||||
### Method 1: Command-line Arguments
|
||||
Specify Allowed directories when starting the server:
|
||||
```bash
|
||||
mcp-server-filesystem /path/to/dir1 /path/to/dir2
|
||||
```
|
||||
|
||||
### Method 2: MCP Roots (Recommended)
|
||||
MCP clients that support [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) can dynamically update the Allowed directories.
|
||||
|
||||
Roots notified by Client to Server, completely replace any server-side Allowed directories when provided.
|
||||
|
||||
**Important**: If server starts without command-line arguments AND client doesn't support roots protocol (or provides empty roots), the server will throw an error during initialization.
|
||||
|
||||
This is the recommended method, as this enables runtime directory updates via `roots/list_changed` notifications without server restart, providing a more flexible and modern integration experience.
|
||||
|
||||
### How It Works
|
||||
|
||||
The server's directory access control follows this flow:
|
||||
|
||||
1. **Server Startup**
|
||||
- Server starts with directories from command-line arguments (if provided)
|
||||
- If no arguments provided, server starts with empty allowed directories
|
||||
|
||||
2. **Client Connection & Initialization**
|
||||
- Client connects and sends `initialize` request with capabilities
|
||||
- Server checks if client supports roots protocol (`capabilities.roots`)
|
||||
|
||||
3. **Roots Protocol Handling** (if client supports roots)
|
||||
- **On initialization**: Server requests roots from client via `roots/list`
|
||||
- Client responds with its configured roots
|
||||
- Server replaces ALL allowed directories with client's roots
|
||||
- **On runtime updates**: Client can send `notifications/roots/list_changed`
|
||||
- Server requests updated roots and replaces allowed directories again
|
||||
|
||||
4. **Fallback Behavior** (if client doesn't support roots)
|
||||
- Server continues using command-line directories only
|
||||
- No dynamic updates possible
|
||||
|
||||
5. **Access Control**
|
||||
- All filesystem operations are restricted to allowed directories
|
||||
- Use `list_allowed_directories` tool to see current directories
|
||||
- Server requires at least ONE allowed directory to operate
|
||||
|
||||
**Note**: The server will only allow operations within directories specified either via `args` or via Roots.
|
||||
|
||||
|
||||
**Note**: The server will only allow operations within directories specified via `args`.
|
||||
|
||||
## API
|
||||
|
||||
@@ -20,10 +70,19 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio
|
||||
|
||||
### Tools
|
||||
|
||||
- **read_file**
|
||||
- Read complete contents of a file
|
||||
- Input: `path` (string)
|
||||
- Reads complete file contents with UTF-8 encoding
|
||||
- **read_text_file**
|
||||
- Read complete contents of a file as text
|
||||
- Inputs:
|
||||
- `path` (string)
|
||||
- `head` (number, optional): First N lines
|
||||
- `tail` (number, optional): Last N lines
|
||||
- Always treats the file as UTF-8 text regardless of extension
|
||||
|
||||
- **read_media_file**
|
||||
- Read an image or audio file
|
||||
- Inputs:
|
||||
- `path` (string)
|
||||
- Streams the file and returns base64 data with the corresponding MIME type
|
||||
|
||||
- **read_multiple_files**
|
||||
- Read multiple files simultaneously
|
||||
@@ -41,22 +100,16 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio
|
||||
- Features:
|
||||
- Line-based and multi-line content matching
|
||||
- Whitespace normalization with indentation preservation
|
||||
- Fuzzy matching with confidence scoring
|
||||
- Multiple simultaneous edits with correct positioning
|
||||
- Indentation style detection and preservation
|
||||
- Git-style diff output with context
|
||||
- Preview changes with dry run mode
|
||||
- Failed match debugging with confidence scores
|
||||
- Inputs:
|
||||
- `path` (string): File to edit
|
||||
- `edits` (array): List of edit operations
|
||||
- `oldText` (string): Text to search for (can be substring)
|
||||
- `newText` (string): Text to replace with
|
||||
- `dryRun` (boolean): Preview changes without applying (default: false)
|
||||
- `options` (object): Optional formatting settings
|
||||
- `preserveIndentation` (boolean): Keep existing indentation (default: true)
|
||||
- `normalizeWhitespace` (boolean): Normalize spaces while preserving structure (default: true)
|
||||
- `partialMatch` (boolean): Enable fuzzy matching (default: true)
|
||||
- Returns detailed diff and match information for dry runs, otherwise applies changes
|
||||
- Best Practice: Always use dryRun first to preview changes before applying them
|
||||
|
||||
@@ -149,6 +202,64 @@ Note: all directories must be mounted to `/projects` by default.
|
||||
}
|
||||
```
|
||||
|
||||
## Usage with VS Code
|
||||
|
||||
For quick installation, click the installation buttons below...
|
||||
|
||||
[](https://insiders.vscode.dev/redirect/mcp/install?name=filesystem&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40modelcontextprotocol%2Fserver-filesystem%22%2C%22%24%7BworkspaceFolder%7D%22%5D%7D) [](https://insiders.vscode.dev/redirect/mcp/install?name=filesystem&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40modelcontextprotocol%2Fserver-filesystem%22%2C%22%24%7BworkspaceFolder%7D%22%5D%7D&quality=insiders)
|
||||
|
||||
[](https://insiders.vscode.dev/redirect/mcp/install?name=filesystem&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22--mount%22%2C%22type%3Dbind%2Csrc%3D%24%7BworkspaceFolder%7D%2Cdst%3D%2Fprojects%2Fworkspace%22%2C%22mcp%2Ffilesystem%22%2C%22%2Fprojects%22%5D%7D) [](https://insiders.vscode.dev/redirect/mcp/install?name=filesystem&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22--mount%22%2C%22type%3Dbind%2Csrc%3D%24%7BworkspaceFolder%7D%2Cdst%3D%2Fprojects%2Fworkspace%22%2C%22mcp%2Ffilesystem%22%2C%22%2Fprojects%22%5D%7D&quality=insiders)
|
||||
|
||||
For manual installation, you can configure the MCP server using one of these methods:
|
||||
|
||||
**Method 1: User Configuration (Recommended)**
|
||||
Add the configuration to your user-level MCP configuration file. Open the Command Palette (`Ctrl + Shift + P`) and run `MCP: Open User Configuration`. This will open your user `mcp.json` file where you can add the server configuration.
|
||||
|
||||
**Method 2: Workspace Configuration**
|
||||
Alternatively, you can add the configuration to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others.
|
||||
|
||||
> For more details about MCP configuration in VS Code, see the [official VS Code MCP documentation](https://code.visualstudio.com/docs/copilot/mcp).
|
||||
|
||||
You can provide sandboxed directories to the server by mounting them to `/projects`. Adding the `ro` flag will make the directory readonly by the server.
|
||||
|
||||
### Docker
|
||||
Note: all directories must be mounted to `/projects` by default.
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"--mount", "type=bind,src=${workspaceFolder},dst=/projects/workspace",
|
||||
"mcp/filesystem",
|
||||
"/projects"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### NPX
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"${workspaceFolder}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
Docker build:
|
||||
|
||||
169
src/filesystem/__tests__/path-utils.test.ts
Normal file
169
src/filesystem/__tests__/path-utils.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import { normalizePath, expandHome, convertToWindowsPath } from '../path-utils.js';
|
||||
|
||||
describe('Path Utilities', () => {
|
||||
describe('convertToWindowsPath', () => {
|
||||
it('leaves Unix paths unchanged', () => {
|
||||
expect(convertToWindowsPath('/usr/local/bin'))
|
||||
.toBe('/usr/local/bin');
|
||||
expect(convertToWindowsPath('/home/user/some path'))
|
||||
.toBe('/home/user/some path');
|
||||
});
|
||||
|
||||
it('converts WSL paths to Windows format', () => {
|
||||
expect(convertToWindowsPath('/mnt/c/NS/MyKindleContent'))
|
||||
.toBe('C:\\NS\\MyKindleContent');
|
||||
});
|
||||
|
||||
it('converts Unix-style Windows paths to Windows format', () => {
|
||||
expect(convertToWindowsPath('/c/NS/MyKindleContent'))
|
||||
.toBe('C:\\NS\\MyKindleContent');
|
||||
});
|
||||
|
||||
it('leaves Windows paths unchanged but ensures backslashes', () => {
|
||||
expect(convertToWindowsPath('C:\\NS\\MyKindleContent'))
|
||||
.toBe('C:\\NS\\MyKindleContent');
|
||||
expect(convertToWindowsPath('C:/NS/MyKindleContent'))
|
||||
.toBe('C:\\NS\\MyKindleContent');
|
||||
});
|
||||
|
||||
it('handles Windows paths with spaces', () => {
|
||||
expect(convertToWindowsPath('C:\\Program Files\\Some App'))
|
||||
.toBe('C:\\Program Files\\Some App');
|
||||
expect(convertToWindowsPath('C:/Program Files/Some App'))
|
||||
.toBe('C:\\Program Files\\Some App');
|
||||
});
|
||||
|
||||
it('handles uppercase and lowercase drive letters', () => {
|
||||
expect(convertToWindowsPath('/mnt/d/some/path'))
|
||||
.toBe('D:\\some\\path');
|
||||
expect(convertToWindowsPath('/d/some/path'))
|
||||
.toBe('D:\\some\\path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePath', () => {
|
||||
it('preserves Unix paths', () => {
|
||||
expect(normalizePath('/usr/local/bin'))
|
||||
.toBe('/usr/local/bin');
|
||||
expect(normalizePath('/home/user/some path'))
|
||||
.toBe('/home/user/some path');
|
||||
expect(normalizePath('"/usr/local/some app/"'))
|
||||
.toBe('/usr/local/some app');
|
||||
});
|
||||
|
||||
it('removes surrounding quotes', () => {
|
||||
expect(normalizePath('"C:\\NS\\My Kindle Content"'))
|
||||
.toBe('C:\\NS\\My Kindle Content');
|
||||
});
|
||||
|
||||
it('normalizes backslashes', () => {
|
||||
expect(normalizePath('C:\\\\NS\\\\MyKindleContent'))
|
||||
.toBe('C:\\NS\\MyKindleContent');
|
||||
});
|
||||
|
||||
it('converts forward slashes to backslashes on Windows', () => {
|
||||
expect(normalizePath('C:/NS/MyKindleContent'))
|
||||
.toBe('C:\\NS\\MyKindleContent');
|
||||
});
|
||||
|
||||
it('handles WSL paths', () => {
|
||||
expect(normalizePath('/mnt/c/NS/MyKindleContent'))
|
||||
.toBe('C:\\NS\\MyKindleContent');
|
||||
});
|
||||
|
||||
it('handles Unix-style Windows paths', () => {
|
||||
expect(normalizePath('/c/NS/MyKindleContent'))
|
||||
.toBe('C:\\NS\\MyKindleContent');
|
||||
});
|
||||
|
||||
it('handles paths with spaces and mixed slashes', () => {
|
||||
expect(normalizePath('C:/NS/My Kindle Content'))
|
||||
.toBe('C:\\NS\\My Kindle Content');
|
||||
expect(normalizePath('/mnt/c/NS/My Kindle Content'))
|
||||
.toBe('C:\\NS\\My Kindle Content');
|
||||
expect(normalizePath('C:\\Program Files (x86)\\App Name'))
|
||||
.toBe('C:\\Program Files (x86)\\App Name');
|
||||
expect(normalizePath('"C:\\Program Files\\App Name"'))
|
||||
.toBe('C:\\Program Files\\App Name');
|
||||
expect(normalizePath(' C:\\Program Files\\App Name '))
|
||||
.toBe('C:\\Program Files\\App Name');
|
||||
});
|
||||
|
||||
it('preserves spaces in all path formats', () => {
|
||||
expect(normalizePath('/mnt/c/Program Files/App Name'))
|
||||
.toBe('C:\\Program Files\\App Name');
|
||||
expect(normalizePath('/c/Program Files/App Name'))
|
||||
.toBe('C:\\Program Files\\App Name');
|
||||
expect(normalizePath('C:/Program Files/App Name'))
|
||||
.toBe('C:\\Program Files\\App Name');
|
||||
});
|
||||
|
||||
it('handles special characters in paths', () => {
|
||||
// Test ampersand in path
|
||||
expect(normalizePath('C:\\NS\\Sub&Folder'))
|
||||
.toBe('C:\\NS\\Sub&Folder');
|
||||
expect(normalizePath('C:/NS/Sub&Folder'))
|
||||
.toBe('C:\\NS\\Sub&Folder');
|
||||
expect(normalizePath('/mnt/c/NS/Sub&Folder'))
|
||||
.toBe('C:\\NS\\Sub&Folder');
|
||||
|
||||
// Test tilde in path (short names in Windows)
|
||||
expect(normalizePath('C:\\NS\\MYKIND~1'))
|
||||
.toBe('C:\\NS\\MYKIND~1');
|
||||
expect(normalizePath('/Users/NEMANS~1/FOLDER~2/SUBFO~1/Public/P12PST~1'))
|
||||
.toBe('/Users/NEMANS~1/FOLDER~2/SUBFO~1/Public/P12PST~1');
|
||||
|
||||
// Test other special characters
|
||||
expect(normalizePath('C:\\Path with #hash'))
|
||||
.toBe('C:\\Path with #hash');
|
||||
expect(normalizePath('C:\\Path with (parentheses)'))
|
||||
.toBe('C:\\Path with (parentheses)');
|
||||
expect(normalizePath('C:\\Path with [brackets]'))
|
||||
.toBe('C:\\Path with [brackets]');
|
||||
expect(normalizePath('C:\\Path with @at+plus$dollar%percent'))
|
||||
.toBe('C:\\Path with @at+plus$dollar%percent');
|
||||
});
|
||||
|
||||
it('capitalizes lowercase drive letters for Windows paths', () => {
|
||||
expect(normalizePath('c:/windows/system32'))
|
||||
.toBe('C:\\windows\\system32');
|
||||
expect(normalizePath('/mnt/d/my/folder')) // WSL path with lowercase drive
|
||||
.toBe('D:\\my\\folder');
|
||||
expect(normalizePath('/e/another/folder')) // Unix-style Windows path with lowercase drive
|
||||
.toBe('E:\\another\\folder');
|
||||
});
|
||||
|
||||
it('handles UNC paths correctly', () => {
|
||||
// UNC paths should preserve the leading double backslash
|
||||
const uncPath = '\\\\SERVER\\share\\folder';
|
||||
expect(normalizePath(uncPath)).toBe('\\\\SERVER\\share\\folder');
|
||||
|
||||
// Test UNC path with double backslashes that need normalization
|
||||
const uncPathWithDoubles = '\\\\\\\\SERVER\\\\share\\\\folder';
|
||||
expect(normalizePath(uncPathWithDoubles)).toBe('\\\\SERVER\\share\\folder');
|
||||
});
|
||||
|
||||
it('returns normalized non-Windows/WSL/Unix-style Windows paths as is after basic normalization', () => {
|
||||
// Relative path
|
||||
const relativePath = 'some/relative/path';
|
||||
expect(normalizePath(relativePath)).toBe(relativePath.replace(/\//g, '\\'));
|
||||
|
||||
// A path that looks somewhat absolute but isn't a drive or recognized Unix root for Windows conversion
|
||||
const otherAbsolutePath = '\\someserver\\share\\file';
|
||||
expect(normalizePath(otherAbsolutePath)).toBe(otherAbsolutePath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandHome', () => {
|
||||
it('expands ~ to home directory', () => {
|
||||
const result = expandHome('~/test');
|
||||
expect(result).toContain('test');
|
||||
expect(result).not.toContain('~');
|
||||
});
|
||||
|
||||
it('leaves other paths unchanged', () => {
|
||||
expect(expandHome('C:/test')).toBe('C:/test');
|
||||
});
|
||||
});
|
||||
});
|
||||
844
src/filesystem/__tests__/path-validation.test.ts
Normal file
844
src/filesystem/__tests__/path-validation.test.ts
Normal file
@@ -0,0 +1,844 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import { isPathWithinAllowedDirectories } from '../path-validation.js';
|
||||
|
||||
describe('Path Validation', () => {
|
||||
it('allows exact directory match', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project', allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows subdirectories', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/src', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/src/index.js', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/deeply/nested/file.txt', allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks similar directory names (prefix vulnerability)', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project2', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project_backup', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project-old', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/projectile', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project.bak', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks paths outside allowed directories', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
expect(isPathWithinAllowedDirectories('/home/user/other', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/etc/passwd', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles multiple allowed directories', () => {
|
||||
const allowed = ['/home/user/project1', '/home/user/project2'];
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project1/src', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project2/src', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project3', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project1_backup', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project2-old', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks parent and sibling directories', () => {
|
||||
const allowed = ['/test/allowed'];
|
||||
|
||||
// Parent directory
|
||||
expect(isPathWithinAllowedDirectories('/test', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/', allowed)).toBe(false);
|
||||
|
||||
// Sibling with common prefix
|
||||
expect(isPathWithinAllowedDirectories('/test/allowed_sibling', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/test/allowed2', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles paths with special characters', () => {
|
||||
const allowed = ['/home/user/my-project (v2)'];
|
||||
|
||||
expect(isPathWithinAllowedDirectories('/home/user/my-project (v2)', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/my-project (v2)/src', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/my-project (v2)_backup', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/my-project', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
describe('Input validation', () => {
|
||||
it('rejects empty inputs', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
expect(isPathWithinAllowedDirectories('', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('handles trailing separators correctly', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
// Path with trailing separator should still match
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/', allowed)).toBe(true);
|
||||
|
||||
// Allowed directory with trailing separator
|
||||
const allowedWithSep = ['/home/user/project/'];
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project', allowedWithSep)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/', allowedWithSep)).toBe(true);
|
||||
|
||||
// Should still block similar names with or without trailing separators
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project2', allowedWithSep)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project2', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project2/', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('skips empty directory entries in allowed list', () => {
|
||||
const allowed = ['', '/home/user/project', ''];
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/src', allowed)).toBe(true);
|
||||
|
||||
// Should still validate properly with empty entries
|
||||
expect(isPathWithinAllowedDirectories('/home/user/other', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles Windows paths with trailing separators', () => {
|
||||
if (path.sep === '\\') {
|
||||
const allowed = ['C:\\Users\\project'];
|
||||
|
||||
// Path with trailing separator
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users\\project\\', allowed)).toBe(true);
|
||||
|
||||
// Allowed with trailing separator
|
||||
const allowedWithSep = ['C:\\Users\\project\\'];
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users\\project', allowedWithSep)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users\\project\\', allowedWithSep)).toBe(true);
|
||||
|
||||
// Should still block similar names
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users\\project2\\', allowed)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('normalizes relative paths to absolute', () => {
|
||||
const allowed = [process.cwd()];
|
||||
|
||||
// Relative paths get normalized to absolute paths based on cwd
|
||||
expect(isPathWithinAllowedDirectories('relative/path', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('./file', allowed)).toBe(true);
|
||||
|
||||
// Parent directory references that escape allowed directory
|
||||
const parentAllowed = ['/home/user/project'];
|
||||
expect(isPathWithinAllowedDirectories('../parent', parentAllowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for relative paths in allowed directories', () => {
|
||||
const badAllowed = ['relative/path', '/some/other/absolute/path'];
|
||||
|
||||
// Relative paths in allowed dirs are normalized to absolute based on cwd
|
||||
// The normalized 'relative/path' won't match our test path
|
||||
expect(isPathWithinAllowedDirectories('/some/other/absolute/path/file', badAllowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/absolute/path/file', badAllowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles null and undefined inputs gracefully', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
// Should return false, not crash
|
||||
expect(isPathWithinAllowedDirectories(null as any, allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories(undefined as any, allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/path', null as any)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/path', undefined as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unicode and special characters', () => {
|
||||
it('handles unicode characters in paths', () => {
|
||||
const allowed = ['/home/user/café'];
|
||||
|
||||
expect(isPathWithinAllowedDirectories('/home/user/café', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/café/file', allowed)).toBe(true);
|
||||
|
||||
// Different unicode representation won't match (not normalized)
|
||||
const decomposed = '/home/user/cafe\u0301'; // e + combining accent
|
||||
expect(isPathWithinAllowedDirectories(decomposed, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles paths with spaces correctly', () => {
|
||||
const allowed = ['/home/user/my project'];
|
||||
|
||||
expect(isPathWithinAllowedDirectories('/home/user/my project', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/my project/file', allowed)).toBe(true);
|
||||
|
||||
// Partial matches should fail
|
||||
expect(isPathWithinAllowedDirectories('/home/user/my', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/my proj', allowed)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Overlapping allowed directories', () => {
|
||||
it('handles nested allowed directories correctly', () => {
|
||||
const allowed = ['/home', '/home/user', '/home/user/project'];
|
||||
|
||||
// All paths under /home are allowed
|
||||
expect(isPathWithinAllowedDirectories('/home/anything', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/anything', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/anything', allowed)).toBe(true);
|
||||
|
||||
// First match wins (most permissive)
|
||||
expect(isPathWithinAllowedDirectories('/home/other/deep/path', allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles root directory as allowed', () => {
|
||||
const allowed = ['/'];
|
||||
|
||||
// Everything is allowed under root (dangerous configuration)
|
||||
expect(isPathWithinAllowedDirectories('/', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/any/path', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/etc/passwd', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/secret', allowed)).toBe(true);
|
||||
|
||||
// But only on the same filesystem root
|
||||
if (path.sep === '\\') {
|
||||
expect(isPathWithinAllowedDirectories('D:\\other', ['/'])).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-platform behavior', () => {
|
||||
it('handles Windows-style paths on Windows', () => {
|
||||
if (path.sep === '\\') {
|
||||
const allowed = ['C:\\Users\\project'];
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users\\project', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users\\project\\src', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users\\project2', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users\\project_backup', allowed)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles Unix-style paths on Unix', () => {
|
||||
if (path.sep === '/') {
|
||||
const allowed = ['/home/user/project'];
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/src', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project2', allowed)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation Tests - Path Traversal', () => {
|
||||
it('blocks path traversal attempts', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
// Basic traversal attempts
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/../../../etc/passwd', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/../../other', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/../project2', allowed)).toBe(false);
|
||||
|
||||
// Mixed traversal with valid segments
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/src/../../project2', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/./../../other', allowed)).toBe(false);
|
||||
|
||||
// Multiple traversal sequences
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/../project/../../../etc', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks traversal in allowed directories', () => {
|
||||
const allowed = ['/home/user/project/../safe'];
|
||||
|
||||
// The allowed directory itself should be normalized and safe
|
||||
expect(isPathWithinAllowedDirectories('/home/user/safe/file', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/file', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles complex traversal patterns', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
// Double dots in filenames (not traversal) - these normalize to paths within allowed dir
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/..test', allowed)).toBe(true); // Not traversal
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/test..', allowed)).toBe(true); // Not traversal
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/te..st', allowed)).toBe(true); // Not traversal
|
||||
|
||||
// Actual traversal
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/../test', allowed)).toBe(false); // Is traversal - goes to /home/user/test
|
||||
|
||||
// Edge case: /home/user/project/.. normalizes to /home/user (parent dir)
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/..', allowed)).toBe(false); // Goes to parent
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation Tests - Null Bytes', () => {
|
||||
it('rejects paths with null bytes', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project\x00/etc/passwd', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/test\x00.txt', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('\x00/home/user/project', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/\x00', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects allowed directories with null bytes', () => {
|
||||
const allowed = ['/home/user/project\x00'];
|
||||
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/file', allowed)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation Tests - Special Characters', () => {
|
||||
it('allows percent signs in filenames', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
// Percent is a valid filename character
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/report_50%.pdf', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/Q1_25%_growth', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/%41', allowed)).toBe(true); // File named %41
|
||||
|
||||
// URL encoding is NOT decoded by path.normalize, so these are just odd filenames
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/%2e%2e', allowed)).toBe(true); // File named "%2e%2e"
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/file%20name', allowed)).toBe(true); // File with %20 in name
|
||||
});
|
||||
|
||||
it('handles percent signs in allowed directories', () => {
|
||||
const allowed = ['/home/user/project%20files'];
|
||||
|
||||
// This is a directory literally named "project%20files"
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project%20files/test', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project files/test', allowed)).toBe(false); // Different dir
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Normalization', () => {
|
||||
it('normalizes paths before comparison', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
// Trailing slashes
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project//', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project///', allowed)).toBe(true);
|
||||
|
||||
// Current directory references
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/./src', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/./project/src', allowed)).toBe(true);
|
||||
|
||||
// Multiple slashes
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project//src//file', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home//user//project//src', allowed)).toBe(true);
|
||||
|
||||
// Should still block outside paths
|
||||
expect(isPathWithinAllowedDirectories('/home/user//project2', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles mixed separators correctly', () => {
|
||||
if (path.sep === '\\') {
|
||||
const allowed = ['C:\\Users\\project'];
|
||||
|
||||
// Mixed separators should be normalized
|
||||
expect(isPathWithinAllowedDirectories('C:/Users/project', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users/project\\src', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('C:/Users\\project/src', allowed)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('rejects non-string inputs safely', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
expect(isPathWithinAllowedDirectories(123 as any, allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories({} as any, allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories([] as any, allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories(null as any, allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories(undefined as any, allowed)).toBe(false);
|
||||
|
||||
// Non-string in allowed directories
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project', [123 as any])).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project', [{} as any])).toBe(false);
|
||||
});
|
||||
|
||||
it('handles very long paths', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
// Create a very long path that's still valid
|
||||
const longSubPath = 'a/'.repeat(1000) + 'file.txt';
|
||||
expect(isPathWithinAllowedDirectories(`/home/user/project/${longSubPath}`, allowed)).toBe(true);
|
||||
|
||||
// Very long path that escapes
|
||||
const escapePath = 'a/'.repeat(1000) + '../'.repeat(1001) + 'etc/passwd';
|
||||
expect(isPathWithinAllowedDirectories(`/home/user/project/${escapePath}`, allowed)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Additional Coverage', () => {
|
||||
it('handles allowed directories with traversal that normalizes safely', () => {
|
||||
// These allowed dirs contain traversal but normalize to valid paths
|
||||
const allowed = ['/home/user/../user/project'];
|
||||
|
||||
// Should normalize to /home/user/project and work correctly
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/file', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/other', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles symbolic dots in filenames', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
// Single and double dots as actual filenames (not traversal)
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/.', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/..', allowed)).toBe(false); // This normalizes to parent
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/...', allowed)).toBe(true); // Three dots is a valid filename
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/....', allowed)).toBe(true); // Four dots is a valid filename
|
||||
});
|
||||
|
||||
it('handles UNC paths on Windows', () => {
|
||||
if (path.sep === '\\') {
|
||||
const allowed = ['\\\\server\\share\\project'];
|
||||
|
||||
expect(isPathWithinAllowedDirectories('\\\\server\\share\\project', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('\\\\server\\share\\project\\file', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('\\\\server\\share\\other', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('\\\\other\\share\\project', allowed)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Symlink Tests', () => {
|
||||
let testDir: string;
|
||||
let allowedDir: string;
|
||||
let forbiddenDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fs-error-test-'));
|
||||
allowedDir = path.join(testDir, 'allowed');
|
||||
forbiddenDir = path.join(testDir, 'forbidden');
|
||||
|
||||
await fs.mkdir(allowedDir, { recursive: true });
|
||||
await fs.mkdir(forbiddenDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('validates symlink handling', async () => {
|
||||
// Test with symlinks
|
||||
try {
|
||||
const linkPath = path.join(allowedDir, 'bad-link');
|
||||
const targetPath = path.join(forbiddenDir, 'target.txt');
|
||||
|
||||
await fs.writeFile(targetPath, 'content');
|
||||
await fs.symlink(targetPath, linkPath);
|
||||
|
||||
// In real implementation, this would throw with the resolved path
|
||||
const realPath = await fs.realpath(linkPath);
|
||||
const allowed = [allowedDir];
|
||||
|
||||
// Symlink target should be outside allowed directory
|
||||
expect(isPathWithinAllowedDirectories(realPath, allowed)).toBe(false);
|
||||
} catch (error) {
|
||||
// Skip if no symlink permissions
|
||||
}
|
||||
});
|
||||
|
||||
it('handles non-existent paths correctly', async () => {
|
||||
const newFilePath = path.join(allowedDir, 'subdir', 'newfile.txt');
|
||||
|
||||
// Parent directory doesn't exist
|
||||
try {
|
||||
await fs.access(newFilePath);
|
||||
} catch (error) {
|
||||
expect((error as NodeJS.ErrnoException).code).toBe('ENOENT');
|
||||
}
|
||||
|
||||
// After creating parent, validation should work
|
||||
await fs.mkdir(path.dirname(newFilePath), { recursive: true });
|
||||
const allowed = [allowedDir];
|
||||
expect(isPathWithinAllowedDirectories(newFilePath, allowed)).toBe(true);
|
||||
});
|
||||
|
||||
// Test path resolution consistency for symlinked files
|
||||
it('validates symlinked files consistently between path and resolved forms', async () => {
|
||||
try {
|
||||
// Setup: Create target file in forbidden area
|
||||
const targetFile = path.join(forbiddenDir, 'target.txt');
|
||||
await fs.writeFile(targetFile, 'TARGET_CONTENT');
|
||||
|
||||
// Create symlink inside allowed directory pointing to forbidden file
|
||||
const symlinkPath = path.join(allowedDir, 'link-to-target.txt');
|
||||
await fs.symlink(targetFile, symlinkPath);
|
||||
|
||||
// The symlink path itself passes validation (looks like it's in allowed dir)
|
||||
expect(isPathWithinAllowedDirectories(symlinkPath, [allowedDir])).toBe(true);
|
||||
|
||||
// But the resolved path should fail validation
|
||||
const resolvedPath = await fs.realpath(symlinkPath);
|
||||
expect(isPathWithinAllowedDirectories(resolvedPath, [allowedDir])).toBe(false);
|
||||
|
||||
// Verify the resolved path goes to the forbidden location (normalize both paths for macOS temp dirs)
|
||||
expect(await fs.realpath(resolvedPath)).toBe(await fs.realpath(targetFile));
|
||||
} catch (error) {
|
||||
// Skip if no symlink permissions on the system
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EPERM') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test allowed directory resolution behavior
|
||||
it('validates paths correctly when allowed directory is resolved from symlink', async () => {
|
||||
try {
|
||||
// Setup: Create the actual target directory with content
|
||||
const actualTargetDir = path.join(testDir, 'actual-target');
|
||||
await fs.mkdir(actualTargetDir, { recursive: true });
|
||||
const targetFile = path.join(actualTargetDir, 'file.txt');
|
||||
await fs.writeFile(targetFile, 'FILE_CONTENT');
|
||||
|
||||
// Setup: Create symlink directory that points to target
|
||||
const symlinkDir = path.join(testDir, 'symlink-dir');
|
||||
await fs.symlink(actualTargetDir, symlinkDir);
|
||||
|
||||
// Simulate resolved allowed directory (what the server startup should do)
|
||||
const resolvedAllowedDir = await fs.realpath(symlinkDir);
|
||||
const resolvedTargetDir = await fs.realpath(actualTargetDir);
|
||||
expect(resolvedAllowedDir).toBe(resolvedTargetDir);
|
||||
|
||||
// Test 1: File access through original symlink path should pass validation with resolved allowed dir
|
||||
const fileViaSymlink = path.join(symlinkDir, 'file.txt');
|
||||
const resolvedFile = await fs.realpath(fileViaSymlink);
|
||||
expect(isPathWithinAllowedDirectories(resolvedFile, [resolvedAllowedDir])).toBe(true);
|
||||
|
||||
// Test 2: File access through resolved path should also pass validation
|
||||
const fileViaResolved = path.join(resolvedTargetDir, 'file.txt');
|
||||
expect(isPathWithinAllowedDirectories(fileViaResolved, [resolvedAllowedDir])).toBe(true);
|
||||
|
||||
// Test 3: Demonstrate inconsistent behavior with unresolved allowed directories
|
||||
// If allowed dirs were not resolved (storing symlink paths instead):
|
||||
const unresolvedAllowedDirs = [symlinkDir];
|
||||
// This validation would incorrectly fail for the same content:
|
||||
expect(isPathWithinAllowedDirectories(resolvedFile, unresolvedAllowedDirs)).toBe(false);
|
||||
|
||||
} catch (error) {
|
||||
// Skip if no symlink permissions on the system
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EPERM') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves nested symlink chains completely', async () => {
|
||||
try {
|
||||
// Setup: Create target file in forbidden area
|
||||
const actualTarget = path.join(forbiddenDir, 'target-file.txt');
|
||||
await fs.writeFile(actualTarget, 'FINAL_CONTENT');
|
||||
|
||||
// Create chain of symlinks: allowedFile -> link2 -> link1 -> actualTarget
|
||||
const link1 = path.join(testDir, 'intermediate-link1');
|
||||
const link2 = path.join(testDir, 'intermediate-link2');
|
||||
const allowedFile = path.join(allowedDir, 'seemingly-safe-file');
|
||||
|
||||
await fs.symlink(actualTarget, link1);
|
||||
await fs.symlink(link1, link2);
|
||||
await fs.symlink(link2, allowedFile);
|
||||
|
||||
// The allowed file path passes basic validation
|
||||
expect(isPathWithinAllowedDirectories(allowedFile, [allowedDir])).toBe(true);
|
||||
|
||||
// But complete resolution reveals the forbidden target
|
||||
const fullyResolvedPath = await fs.realpath(allowedFile);
|
||||
expect(isPathWithinAllowedDirectories(fullyResolvedPath, [allowedDir])).toBe(false);
|
||||
expect(await fs.realpath(fullyResolvedPath)).toBe(await fs.realpath(actualTarget));
|
||||
|
||||
} catch (error) {
|
||||
// Skip if no symlink permissions on the system
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EPERM') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Validation Race Condition Tests', () => {
|
||||
let testDir: string;
|
||||
let allowedDir: string;
|
||||
let forbiddenDir: string;
|
||||
let targetFile: string;
|
||||
let testPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'race-test-'));
|
||||
allowedDir = path.join(testDir, 'allowed');
|
||||
forbiddenDir = path.join(testDir, 'outside');
|
||||
targetFile = path.join(forbiddenDir, 'target.txt');
|
||||
testPath = path.join(allowedDir, 'test.txt');
|
||||
|
||||
await fs.mkdir(allowedDir, { recursive: true });
|
||||
await fs.mkdir(forbiddenDir, { recursive: true });
|
||||
await fs.writeFile(targetFile, 'ORIGINAL CONTENT', 'utf-8');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('validates non-existent file paths based on parent directory', async () => {
|
||||
const allowed = [allowedDir];
|
||||
|
||||
expect(isPathWithinAllowedDirectories(testPath, allowed)).toBe(true);
|
||||
await expect(fs.access(testPath)).rejects.toThrow();
|
||||
|
||||
const parentDir = path.dirname(testPath);
|
||||
expect(isPathWithinAllowedDirectories(parentDir, allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it('demonstrates symlink race condition allows writing outside allowed directories', async () => {
|
||||
const allowed = [allowedDir];
|
||||
|
||||
await expect(fs.access(testPath)).rejects.toThrow();
|
||||
expect(isPathWithinAllowedDirectories(testPath, allowed)).toBe(true);
|
||||
|
||||
await fs.symlink(targetFile, testPath);
|
||||
await fs.writeFile(testPath, 'MODIFIED CONTENT', 'utf-8');
|
||||
|
||||
const targetContent = await fs.readFile(targetFile, 'utf-8');
|
||||
expect(targetContent).toBe('MODIFIED CONTENT');
|
||||
|
||||
const resolvedPath = await fs.realpath(testPath);
|
||||
expect(isPathWithinAllowedDirectories(resolvedPath, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('shows timing differences between validation approaches', async () => {
|
||||
const allowed = [allowedDir];
|
||||
|
||||
const validation1 = isPathWithinAllowedDirectories(testPath, allowed);
|
||||
expect(validation1).toBe(true);
|
||||
|
||||
await fs.symlink(targetFile, testPath);
|
||||
|
||||
const resolvedPath = await fs.realpath(testPath);
|
||||
const validation2 = isPathWithinAllowedDirectories(resolvedPath, allowed);
|
||||
expect(validation2).toBe(false);
|
||||
|
||||
expect(validation1).not.toBe(validation2);
|
||||
});
|
||||
|
||||
it('validates directory creation timing', async () => {
|
||||
const allowed = [allowedDir];
|
||||
const testDir = path.join(allowedDir, 'newdir');
|
||||
|
||||
expect(isPathWithinAllowedDirectories(testDir, allowed)).toBe(true);
|
||||
|
||||
await fs.symlink(forbiddenDir, testDir);
|
||||
|
||||
expect(isPathWithinAllowedDirectories(testDir, allowed)).toBe(true);
|
||||
|
||||
const resolved = await fs.realpath(testDir);
|
||||
expect(isPathWithinAllowedDirectories(resolved, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('demonstrates exclusive file creation behavior', async () => {
|
||||
const allowed = [allowedDir];
|
||||
|
||||
await fs.symlink(targetFile, testPath);
|
||||
|
||||
await expect(fs.open(testPath, 'wx')).rejects.toThrow(/EEXIST/);
|
||||
|
||||
await fs.writeFile(testPath, 'NEW CONTENT', 'utf-8');
|
||||
const targetContent = await fs.readFile(targetFile, 'utf-8');
|
||||
expect(targetContent).toBe('NEW CONTENT');
|
||||
});
|
||||
|
||||
it('should use resolved parent paths for non-existent files', async () => {
|
||||
const allowed = [allowedDir];
|
||||
|
||||
const symlinkDir = path.join(allowedDir, 'link');
|
||||
await fs.symlink(forbiddenDir, symlinkDir);
|
||||
|
||||
const fileThroughSymlink = path.join(symlinkDir, 'newfile.txt');
|
||||
|
||||
expect(fileThroughSymlink.startsWith(allowedDir)).toBe(true);
|
||||
|
||||
const parentDir = path.dirname(fileThroughSymlink);
|
||||
const resolvedParent = await fs.realpath(parentDir);
|
||||
expect(isPathWithinAllowedDirectories(resolvedParent, allowed)).toBe(false);
|
||||
|
||||
const expectedSafePath = path.join(resolvedParent, path.basename(fileThroughSymlink));
|
||||
expect(isPathWithinAllowedDirectories(expectedSafePath, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('demonstrates parent directory symlink traversal', async () => {
|
||||
const allowed = [allowedDir];
|
||||
const deepPath = path.join(allowedDir, 'sub1', 'sub2', 'file.txt');
|
||||
|
||||
expect(isPathWithinAllowedDirectories(deepPath, allowed)).toBe(true);
|
||||
|
||||
const sub1Path = path.join(allowedDir, 'sub1');
|
||||
await fs.symlink(forbiddenDir, sub1Path);
|
||||
|
||||
await fs.mkdir(path.join(sub1Path, 'sub2'), { recursive: true });
|
||||
await fs.writeFile(deepPath, 'CONTENT', 'utf-8');
|
||||
|
||||
const realPath = await fs.realpath(deepPath);
|
||||
const realAllowedDir = await fs.realpath(allowedDir);
|
||||
const realForbiddenDir = await fs.realpath(forbiddenDir);
|
||||
|
||||
expect(realPath.startsWith(realAllowedDir)).toBe(false);
|
||||
expect(realPath.startsWith(realForbiddenDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('should prevent race condition between validatePath and file operation', async () => {
|
||||
const allowed = [allowedDir];
|
||||
const racePath = path.join(allowedDir, 'race-file.txt');
|
||||
const targetFile = path.join(forbiddenDir, 'target.txt');
|
||||
|
||||
await fs.writeFile(targetFile, 'ORIGINAL CONTENT', 'utf-8');
|
||||
|
||||
// Path validation would pass (file doesn't exist, parent is in allowed dir)
|
||||
expect(await fs.access(racePath).then(() => false).catch(() => true)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories(racePath, allowed)).toBe(true);
|
||||
|
||||
// Race condition: symlink created after validation but before write
|
||||
await fs.symlink(targetFile, racePath);
|
||||
|
||||
// With exclusive write flag, write should fail on symlink
|
||||
await expect(
|
||||
fs.writeFile(racePath, 'NEW CONTENT', { encoding: 'utf-8', flag: 'wx' })
|
||||
).rejects.toThrow(/EEXIST/);
|
||||
|
||||
// Verify content unchanged
|
||||
const targetContent = await fs.readFile(targetFile, 'utf-8');
|
||||
expect(targetContent).toBe('ORIGINAL CONTENT');
|
||||
|
||||
// The symlink exists but write was blocked
|
||||
const actualWritePath = await fs.realpath(racePath);
|
||||
expect(actualWritePath).toBe(await fs.realpath(targetFile));
|
||||
expect(isPathWithinAllowedDirectories(actualWritePath, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow overwrites to legitimate files within allowed directories', async () => {
|
||||
const allowed = [allowedDir];
|
||||
const legitFile = path.join(allowedDir, 'legit-file.txt');
|
||||
|
||||
// Create a legitimate file
|
||||
await fs.writeFile(legitFile, 'ORIGINAL', 'utf-8');
|
||||
|
||||
// Opening with w should work for legitimate files
|
||||
const fd = await fs.open(legitFile, 'w');
|
||||
try {
|
||||
await fd.write('UPDATED', 0, 'utf-8');
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
|
||||
const content = await fs.readFile(legitFile, 'utf-8');
|
||||
expect(content).toBe('UPDATED');
|
||||
});
|
||||
|
||||
it('should handle symlinks that point within allowed directories', async () => {
|
||||
const allowed = [allowedDir];
|
||||
const targetFile = path.join(allowedDir, 'target.txt');
|
||||
const symlinkPath = path.join(allowedDir, 'symlink.txt');
|
||||
|
||||
// Create target file within allowed directory
|
||||
await fs.writeFile(targetFile, 'TARGET CONTENT', 'utf-8');
|
||||
|
||||
// Create symlink pointing to allowed file
|
||||
await fs.symlink(targetFile, symlinkPath);
|
||||
|
||||
// Opening symlink with w follows it to the target
|
||||
const fd = await fs.open(symlinkPath, 'w');
|
||||
try {
|
||||
await fd.write('UPDATED VIA SYMLINK', 0, 'utf-8');
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
|
||||
// Both symlink and target should show updated content
|
||||
const symlinkContent = await fs.readFile(symlinkPath, 'utf-8');
|
||||
const targetContent = await fs.readFile(targetFile, 'utf-8');
|
||||
expect(symlinkContent).toBe('UPDATED VIA SYMLINK');
|
||||
expect(targetContent).toBe('UPDATED VIA SYMLINK');
|
||||
});
|
||||
|
||||
it('should prevent overwriting files through symlinks pointing outside allowed directories', async () => {
|
||||
const allowed = [allowedDir];
|
||||
const legitFile = path.join(allowedDir, 'existing.txt');
|
||||
const targetFile = path.join(forbiddenDir, 'target.txt');
|
||||
|
||||
// Create a legitimate file first
|
||||
await fs.writeFile(legitFile, 'LEGIT CONTENT', 'utf-8');
|
||||
|
||||
// Create target file in forbidden directory
|
||||
await fs.writeFile(targetFile, 'FORBIDDEN CONTENT', 'utf-8');
|
||||
|
||||
// Now replace the legitimate file with a symlink to forbidden location
|
||||
await fs.unlink(legitFile);
|
||||
await fs.symlink(targetFile, legitFile);
|
||||
|
||||
// Simulate the server's validation logic
|
||||
const stats = await fs.lstat(legitFile);
|
||||
expect(stats.isSymbolicLink()).toBe(true);
|
||||
|
||||
const realPath = await fs.realpath(legitFile);
|
||||
expect(isPathWithinAllowedDirectories(realPath, allowed)).toBe(false);
|
||||
|
||||
// With atomic rename, symlinks are replaced not followed
|
||||
// So this test now demonstrates the protection
|
||||
|
||||
// Verify content remains unchanged
|
||||
const targetContent = await fs.readFile(targetFile, 'utf-8');
|
||||
expect(targetContent).toBe('FORBIDDEN CONTENT');
|
||||
});
|
||||
|
||||
it('demonstrates race condition in read operations', async () => {
|
||||
const allowed = [allowedDir];
|
||||
const legitFile = path.join(allowedDir, 'readable.txt');
|
||||
const secretFile = path.join(forbiddenDir, 'secret.txt');
|
||||
|
||||
// Create legitimate file
|
||||
await fs.writeFile(legitFile, 'PUBLIC CONTENT', 'utf-8');
|
||||
|
||||
// Create secret file in forbidden directory
|
||||
await fs.writeFile(secretFile, 'SECRET CONTENT', 'utf-8');
|
||||
|
||||
// Step 1: validatePath would pass for legitimate file
|
||||
expect(isPathWithinAllowedDirectories(legitFile, allowed)).toBe(true);
|
||||
|
||||
// Step 2: Race condition - replace file with symlink after validation
|
||||
await fs.unlink(legitFile);
|
||||
await fs.symlink(secretFile, legitFile);
|
||||
|
||||
// Step 3: Read operation follows symlink to forbidden location
|
||||
const content = await fs.readFile(legitFile, 'utf-8');
|
||||
|
||||
// This shows the vulnerability - we read forbidden content
|
||||
expect(content).toBe('SECRET CONTENT');
|
||||
expect(isPathWithinAllowedDirectories(await fs.realpath(legitFile), allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifies rename does not follow symlinks', async () => {
|
||||
const allowed = [allowedDir];
|
||||
const tempFile = path.join(allowedDir, 'temp.txt');
|
||||
const targetSymlink = path.join(allowedDir, 'target-symlink.txt');
|
||||
const forbiddenTarget = path.join(forbiddenDir, 'forbidden-target.txt');
|
||||
|
||||
// Create forbidden target
|
||||
await fs.writeFile(forbiddenTarget, 'ORIGINAL CONTENT', 'utf-8');
|
||||
|
||||
// Create symlink pointing to forbidden location
|
||||
await fs.symlink(forbiddenTarget, targetSymlink);
|
||||
|
||||
// Write temp file
|
||||
await fs.writeFile(tempFile, 'NEW CONTENT', 'utf-8');
|
||||
|
||||
// Rename temp file to symlink path
|
||||
await fs.rename(tempFile, targetSymlink);
|
||||
|
||||
// Check what happened
|
||||
const symlinkExists = await fs.lstat(targetSymlink).then(() => true).catch(() => false);
|
||||
const isSymlink = symlinkExists && (await fs.lstat(targetSymlink)).isSymbolicLink();
|
||||
const targetContent = await fs.readFile(targetSymlink, 'utf-8');
|
||||
const forbiddenContent = await fs.readFile(forbiddenTarget, 'utf-8');
|
||||
|
||||
// Rename should replace the symlink with a regular file
|
||||
expect(isSymlink).toBe(false);
|
||||
expect(targetContent).toBe('NEW CONTENT');
|
||||
expect(forbiddenContent).toBe('ORIGINAL CONTENT'); // Unchanged
|
||||
});
|
||||
});
|
||||
});
|
||||
84
src/filesystem/__tests__/roots-utils.test.ts
Normal file
84
src/filesystem/__tests__/roots-utils.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
||||
import { getValidRootDirectories } from '../roots-utils.js';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, realpathSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import type { Root } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
describe('getValidRootDirectories', () => {
|
||||
let testDir1: string;
|
||||
let testDir2: string;
|
||||
let testDir3: string;
|
||||
let testFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create test directories
|
||||
testDir1 = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-roots-test1-')));
|
||||
testDir2 = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-roots-test2-')));
|
||||
testDir3 = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-roots-test3-')));
|
||||
|
||||
// Create a test file (not a directory)
|
||||
testFile = join(testDir1, 'test-file.txt');
|
||||
writeFileSync(testFile, 'test content');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup
|
||||
rmSync(testDir1, { recursive: true, force: true });
|
||||
rmSync(testDir2, { recursive: true, force: true });
|
||||
rmSync(testDir3, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('valid directory processing', () => {
|
||||
it('should process all URI formats and edge cases', async () => {
|
||||
const roots = [
|
||||
{ uri: `file://${testDir1}`, name: 'File URI' },
|
||||
{ uri: testDir2, name: 'Plain path' },
|
||||
{ uri: testDir3 } // Plain path without name property
|
||||
];
|
||||
|
||||
const result = await getValidRootDirectories(roots);
|
||||
|
||||
expect(result).toContain(testDir1);
|
||||
expect(result).toContain(testDir2);
|
||||
expect(result).toContain(testDir3);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should normalize complex paths', async () => {
|
||||
const subDir = join(testDir1, 'subdir');
|
||||
mkdirSync(subDir);
|
||||
|
||||
const roots = [
|
||||
{ uri: `file://${testDir1}/./subdir/../subdir`, name: 'Complex Path' }
|
||||
];
|
||||
|
||||
const result = await getValidRootDirectories(roots);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe(subDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
|
||||
it('should handle various error types', async () => {
|
||||
const nonExistentDir = join(tmpdir(), 'non-existent-directory-12345');
|
||||
const invalidPath = '\0invalid\0path'; // Null bytes cause different error types
|
||||
const roots = [
|
||||
{ uri: `file://${testDir1}`, name: 'Valid Dir' },
|
||||
{ uri: `file://${nonExistentDir}`, name: 'Non-existent Dir' },
|
||||
{ uri: `file://${testFile}`, name: 'File Not Dir' },
|
||||
{ uri: `file://${invalidPath}`, name: 'Invalid Path' }
|
||||
];
|
||||
|
||||
const result = await getValidRootDirectories(roots);
|
||||
|
||||
expect(result).toContain(testDir1);
|
||||
expect(result).not.toContain(nonExistentDir);
|
||||
expect(result).not.toContain(testFile);
|
||||
expect(result).not.toContain(invalidPath);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,20 +6,29 @@ import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ToolSchema,
|
||||
RootsListChangedNotificationSchema,
|
||||
type Root,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import fs from "fs/promises";
|
||||
import { createReadStream } from "fs";
|
||||
import path from "path";
|
||||
import os from 'os';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { z } from "zod";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
import { diffLines, createTwoFilesPatch } from 'diff';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { isPathWithinAllowedDirectories } from './path-validation.js';
|
||||
import { getValidRootDirectories } from './roots-utils.js';
|
||||
|
||||
// Command line argument parsing
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0) {
|
||||
console.error("Usage: mcp-server-filesystem <allowed-directory> [additional-directories...]");
|
||||
process.exit(1);
|
||||
console.error("Usage: mcp-server-filesystem [allowed-directory] [additional-directories...]");
|
||||
console.error("Note: Allowed directories can be provided via:");
|
||||
console.error(" 1. Command-line arguments (shown above)");
|
||||
console.error(" 2. MCP roots protocol (if client supports it)");
|
||||
console.error("At least one directory must be provided by EITHER method for the server to operate.");
|
||||
}
|
||||
|
||||
// Normalize all paths consistently
|
||||
@@ -34,15 +43,27 @@ function expandHome(filepath: string): string {
|
||||
return filepath;
|
||||
}
|
||||
|
||||
// Store allowed directories in normalized form
|
||||
const allowedDirectories = args.map(dir =>
|
||||
normalizePath(path.resolve(expandHome(dir)))
|
||||
// Store allowed directories in normalized and resolved form
|
||||
let allowedDirectories = await Promise.all(
|
||||
args.map(async (dir) => {
|
||||
const expanded = expandHome(dir);
|
||||
const absolute = path.resolve(expanded);
|
||||
try {
|
||||
// Resolve symlinks in allowed directories during startup
|
||||
const resolved = await fs.realpath(absolute);
|
||||
return normalizePath(resolved);
|
||||
} catch (error) {
|
||||
// If we can't resolve (doesn't exist), use the normalized absolute path
|
||||
// This allows configuring allowed dirs that will be created later
|
||||
return normalizePath(absolute);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Validate that all directories exist and are accessible
|
||||
await Promise.all(args.map(async (dir) => {
|
||||
try {
|
||||
const stats = await fs.stat(dir);
|
||||
const stats = await fs.stat(expandHome(dir));
|
||||
if (!stats.isDirectory()) {
|
||||
console.error(`Error: ${dir} is not a directory`);
|
||||
process.exit(1);
|
||||
@@ -63,7 +84,7 @@ async function validatePath(requestedPath: string): Promise<string> {
|
||||
const normalizedRequested = normalizePath(absolute);
|
||||
|
||||
// Check if path is within allowed directories
|
||||
const isAllowed = allowedDirectories.some(dir => normalizedRequested.startsWith(dir));
|
||||
const isAllowed = isPathWithinAllowedDirectories(normalizedRequested, allowedDirectories);
|
||||
if (!isAllowed) {
|
||||
throw new Error(`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}`);
|
||||
}
|
||||
@@ -72,31 +93,38 @@ async function validatePath(requestedPath: string): Promise<string> {
|
||||
try {
|
||||
const realPath = await fs.realpath(absolute);
|
||||
const normalizedReal = normalizePath(realPath);
|
||||
const isRealPathAllowed = allowedDirectories.some(dir => normalizedReal.startsWith(dir));
|
||||
if (!isRealPathAllowed) {
|
||||
throw new Error("Access denied - symlink target outside allowed directories");
|
||||
if (!isPathWithinAllowedDirectories(normalizedReal, allowedDirectories)) {
|
||||
throw new Error(`Access denied - symlink target outside allowed directories: ${realPath} not in ${allowedDirectories.join(', ')}`);
|
||||
}
|
||||
return realPath;
|
||||
} catch (error) {
|
||||
// For new files that don't exist yet, verify parent directory
|
||||
const parentDir = path.dirname(absolute);
|
||||
try {
|
||||
const realParentPath = await fs.realpath(parentDir);
|
||||
const normalizedParent = normalizePath(realParentPath);
|
||||
const isParentAllowed = allowedDirectories.some(dir => normalizedParent.startsWith(dir));
|
||||
if (!isParentAllowed) {
|
||||
throw new Error("Access denied - parent directory outside allowed directories");
|
||||
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 absolute;
|
||||
} catch {
|
||||
throw new Error(`Parent directory does not exist: ${parentDir}`);
|
||||
}
|
||||
return absolute;
|
||||
} catch {
|
||||
throw new Error(`Parent directory does not exist: ${parentDir}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Schema definitions
|
||||
const ReadFileArgsSchema = z.object({
|
||||
const ReadTextFileArgsSchema = z.object({
|
||||
path: z.string(),
|
||||
tail: z.number().optional().describe('If provided, returns only the last N lines of the file'),
|
||||
head: z.number().optional().describe('If provided, returns only the first N lines of the file')
|
||||
});
|
||||
|
||||
const ReadMediaFileArgsSchema = z.object({
|
||||
path: z.string()
|
||||
});
|
||||
|
||||
const ReadMultipleFilesArgsSchema = z.object({
|
||||
@@ -127,6 +155,11 @@ const ListDirectoryArgsSchema = z.object({
|
||||
path: z.string(),
|
||||
});
|
||||
|
||||
const ListDirectoryWithSizesArgsSchema = z.object({
|
||||
path: z.string(),
|
||||
sortBy: z.enum(['name', 'size']).optional().default('name').describe('Sort entries by name or size'),
|
||||
});
|
||||
|
||||
const DirectoryTreeArgsSchema = z.object({
|
||||
path: z.string(),
|
||||
});
|
||||
@@ -324,24 +357,170 @@ async function applyFileEdits(
|
||||
const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`;
|
||||
|
||||
if (!dryRun) {
|
||||
await fs.writeFile(filePath, modifiedContent, 'utf-8');
|
||||
// Security: Use atomic rename to prevent race conditions where symlinks
|
||||
// could be created between validation and write. Rename operations
|
||||
// replace the target file atomically and don't follow symlinks.
|
||||
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tempPath, modifiedContent, 'utf-8');
|
||||
await fs.rename(tempPath, filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
await fs.unlink(tempPath);
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return formattedDiff;
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function formatSize(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
if (i === 0) return `${bytes} ${units[i]}`;
|
||||
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
|
||||
}
|
||||
|
||||
// Memory-efficient implementation to get the last N lines of a file
|
||||
async function tailFile(filePath: string, numLines: number): Promise<string> {
|
||||
const CHUNK_SIZE = 1024; // Read 1KB at a time
|
||||
const stats = await fs.stat(filePath);
|
||||
const fileSize = stats.size;
|
||||
|
||||
if (fileSize === 0) return '';
|
||||
|
||||
// Open file for reading
|
||||
const fileHandle = await fs.open(filePath, 'r');
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
let position = fileSize;
|
||||
let chunk = Buffer.alloc(CHUNK_SIZE);
|
||||
let linesFound = 0;
|
||||
let remainingText = '';
|
||||
|
||||
// Read chunks from the end of the file until we have enough lines
|
||||
while (position > 0 && linesFound < numLines) {
|
||||
const size = Math.min(CHUNK_SIZE, position);
|
||||
position -= size;
|
||||
|
||||
const { bytesRead } = await fileHandle.read(chunk, 0, size, position);
|
||||
if (!bytesRead) break;
|
||||
|
||||
// Get the chunk as a string and prepend any remaining text from previous iteration
|
||||
const readData = chunk.slice(0, bytesRead).toString('utf-8');
|
||||
const chunkText = readData + remainingText;
|
||||
|
||||
// Split by newlines and count
|
||||
const chunkLines = normalizeLineEndings(chunkText).split('\n');
|
||||
|
||||
// If this isn't the end of the file, the first line is likely incomplete
|
||||
// Save it to prepend to the next chunk
|
||||
if (position > 0) {
|
||||
remainingText = chunkLines[0];
|
||||
chunkLines.shift(); // Remove the first (incomplete) line
|
||||
}
|
||||
|
||||
// Add lines to our result (up to the number we need)
|
||||
for (let i = chunkLines.length - 1; i >= 0 && linesFound < numLines; i--) {
|
||||
lines.unshift(chunkLines[i]);
|
||||
linesFound++;
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
// New function to get the first N lines of a file
|
||||
async function headFile(filePath: string, numLines: number): Promise<string> {
|
||||
const fileHandle = await fs.open(filePath, 'r');
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
let buffer = '';
|
||||
let bytesRead = 0;
|
||||
const chunk = Buffer.alloc(1024); // 1KB buffer
|
||||
|
||||
// Read chunks and count lines until we have enough or reach EOF
|
||||
while (lines.length < numLines) {
|
||||
const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead);
|
||||
if (result.bytesRead === 0) break; // End of file
|
||||
bytesRead += result.bytesRead;
|
||||
buffer += chunk.slice(0, result.bytesRead).toString('utf-8');
|
||||
|
||||
const newLineIndex = buffer.lastIndexOf('\n');
|
||||
if (newLineIndex !== -1) {
|
||||
const completeLines = buffer.slice(0, newLineIndex).split('\n');
|
||||
buffer = buffer.slice(newLineIndex + 1);
|
||||
for (const line of completeLines) {
|
||||
lines.push(line);
|
||||
if (lines.length >= numLines) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there is leftover content and we still need lines, add it
|
||||
if (buffer.length > 0 && lines.length < numLines) {
|
||||
lines.push(buffer);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Reads a file as a stream of buffers, concatenates them, and then encodes
|
||||
// the result to a Base64 string. This is a memory-efficient way to handle
|
||||
// binary data from a stream before the final encoding.
|
||||
async function readFileAsBase64Stream(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = createReadStream(filePath);
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk) => {
|
||||
chunks.push(chunk as Buffer);
|
||||
});
|
||||
stream.on('end', () => {
|
||||
const finalBuffer = Buffer.concat(chunks);
|
||||
resolve(finalBuffer.toString('base64'));
|
||||
});
|
||||
stream.on('error', (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
// Tool handlers
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: "read_file",
|
||||
description: "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.",
|
||||
inputSchema: zodToJsonSchema(ReadTextFileArgsSchema) as ToolInput,
|
||||
},
|
||||
{
|
||||
name: "read_text_file",
|
||||
description:
|
||||
"Read the complete contents of a file from the file system. " +
|
||||
"Read the complete contents of a file from the file system as text. " +
|
||||
"Handles various text encodings and provides detailed error messages " +
|
||||
"if the file cannot be read. Use this tool when you need to examine " +
|
||||
"the contents of a single file. Only works within allowed directories.",
|
||||
inputSchema: zodToJsonSchema(ReadFileArgsSchema) as ToolInput,
|
||||
"the contents of a single file. Use the 'head' parameter to read only " +
|
||||
"the first N lines of a file, or the 'tail' parameter to read only " +
|
||||
"the last N lines of a file. Operates on the file as text regardless of extension. " +
|
||||
"Only works within allowed directories.",
|
||||
inputSchema: zodToJsonSchema(ReadTextFileArgsSchema) as ToolInput,
|
||||
},
|
||||
{
|
||||
name: "read_media_file",
|
||||
description:
|
||||
"Read an image or audio file. Returns the base64 encoded data and MIME type. " +
|
||||
"Only works within allowed directories.",
|
||||
inputSchema: zodToJsonSchema(ReadMediaFileArgsSchema) as ToolInput,
|
||||
},
|
||||
{
|
||||
name: "read_multiple_files",
|
||||
@@ -387,6 +566,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
"finding specific files within a directory. Only works within allowed directories.",
|
||||
inputSchema: zodToJsonSchema(ListDirectoryArgsSchema) as ToolInput,
|
||||
},
|
||||
{
|
||||
name: "list_directory_with_sizes",
|
||||
description:
|
||||
"Get a detailed listing of all files and directories in a specified path, including sizes. " +
|
||||
"Results clearly distinguish between files and directories with [FILE] and [DIR] " +
|
||||
"prefixes. This tool is useful for understanding directory structure and " +
|
||||
"finding specific files within a directory. Only works within allowed directories.",
|
||||
inputSchema: zodToJsonSchema(ListDirectoryWithSizesArgsSchema) as ToolInput,
|
||||
},
|
||||
{
|
||||
name: "directory_tree",
|
||||
description:
|
||||
@@ -427,8 +615,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
{
|
||||
name: "list_allowed_directories",
|
||||
description:
|
||||
"Returns the list of directories that this server is allowed to access. " +
|
||||
"Use this to understand which directories are available before trying to access files.",
|
||||
"Returns the list of root directories that this server is allowed to access. " +
|
||||
"Use this to understand which directories are available before trying to access files. ",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
@@ -445,18 +633,72 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
switch (name) {
|
||||
case "read_file": {
|
||||
const parsed = ReadFileArgsSchema.safeParse(args);
|
||||
case "read_file":
|
||||
case "read_text_file": {
|
||||
const parsed = ReadTextFileArgsSchema.safeParse(args);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for read_file: ${parsed.error}`);
|
||||
throw new Error(`Invalid arguments for read_text_file: ${parsed.error}`);
|
||||
}
|
||||
const validPath = await validatePath(parsed.data.path);
|
||||
|
||||
if (parsed.data.head && parsed.data.tail) {
|
||||
throw new Error("Cannot specify both head and tail parameters simultaneously");
|
||||
}
|
||||
|
||||
if (parsed.data.tail) {
|
||||
// Use memory-efficient tail implementation for large files
|
||||
const tailContent = await tailFile(validPath, parsed.data.tail);
|
||||
return {
|
||||
content: [{ type: "text", text: tailContent }],
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.data.head) {
|
||||
// Use memory-efficient head implementation for large files
|
||||
const headContent = await headFile(validPath, parsed.data.head);
|
||||
return {
|
||||
content: [{ type: "text", text: headContent }],
|
||||
};
|
||||
}
|
||||
|
||||
const content = await fs.readFile(validPath, "utf-8");
|
||||
return {
|
||||
content: [{ type: "text", text: content }],
|
||||
};
|
||||
}
|
||||
|
||||
case "read_media_file": {
|
||||
const parsed = ReadMediaFileArgsSchema.safeParse(args);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for read_media_file: ${parsed.error}`);
|
||||
}
|
||||
const validPath = await validatePath(parsed.data.path);
|
||||
const extension = path.extname(validPath).toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".svg": "image/svg+xml",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".flac": "audio/flac",
|
||||
};
|
||||
const mimeType = mimeTypes[extension] || "application/octet-stream";
|
||||
const data = await readFileAsBase64Stream(validPath);
|
||||
const type = mimeType.startsWith("image/")
|
||||
? "image"
|
||||
: mimeType.startsWith("audio/")
|
||||
? "audio"
|
||||
: "blob";
|
||||
return {
|
||||
content: [{ type, data, mimeType }],
|
||||
};
|
||||
}
|
||||
|
||||
case "read_multiple_files": {
|
||||
const parsed = ReadMultipleFilesArgsSchema.safeParse(args);
|
||||
if (!parsed.success) {
|
||||
@@ -485,7 +727,31 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
throw new Error(`Invalid arguments for write_file: ${parsed.error}`);
|
||||
}
|
||||
const validPath = await validatePath(parsed.data.path);
|
||||
await fs.writeFile(validPath, parsed.data.content, "utf-8");
|
||||
|
||||
try {
|
||||
// Security: 'wx' flag ensures exclusive creation - fails if file/symlink exists,
|
||||
// preventing writes through pre-existing symlinks
|
||||
await fs.writeFile(validPath, parsed.data.content, { encoding: "utf-8", flag: 'wx' });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
// Security: Use atomic rename to prevent race conditions where symlinks
|
||||
// could be created between validation and write. Rename operations
|
||||
// replace the target file atomically and don't follow symlinks.
|
||||
const tempPath = `${validPath}.${randomBytes(16).toString('hex')}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tempPath, parsed.data.content, 'utf-8');
|
||||
await fs.rename(tempPath, validPath);
|
||||
} catch (renameError) {
|
||||
try {
|
||||
await fs.unlink(tempPath);
|
||||
} catch {}
|
||||
throw renameError;
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Successfully wrote to ${parsed.data.path}` }],
|
||||
};
|
||||
@@ -530,6 +796,72 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
};
|
||||
}
|
||||
|
||||
case "list_directory_with_sizes": {
|
||||
const parsed = ListDirectoryWithSizesArgsSchema.safeParse(args);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid arguments for list_directory_with_sizes: ${parsed.error}`);
|
||||
}
|
||||
const validPath = await validatePath(parsed.data.path);
|
||||
const entries = await fs.readdir(validPath, { withFileTypes: true });
|
||||
|
||||
// Get detailed information for each entry
|
||||
const detailedEntries = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(validPath, entry.name);
|
||||
try {
|
||||
const stats = await fs.stat(entryPath);
|
||||
return {
|
||||
name: entry.name,
|
||||
isDirectory: entry.isDirectory(),
|
||||
size: stats.size,
|
||||
mtime: stats.mtime
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: entry.name,
|
||||
isDirectory: entry.isDirectory(),
|
||||
size: 0,
|
||||
mtime: new Date(0)
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Sort entries based on sortBy parameter
|
||||
const sortedEntries = [...detailedEntries].sort((a, b) => {
|
||||
if (parsed.data.sortBy === 'size') {
|
||||
return b.size - a.size; // Descending by size
|
||||
}
|
||||
// Default sort by name
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Format the output
|
||||
const formattedEntries = sortedEntries.map(entry =>
|
||||
`${entry.isDirectory ? "[DIR]" : "[FILE]"} ${entry.name.padEnd(30)} ${
|
||||
entry.isDirectory ? "" : formatSize(entry.size).padStart(10)
|
||||
}`
|
||||
);
|
||||
|
||||
// Add summary
|
||||
const totalFiles = detailedEntries.filter(e => !e.isDirectory).length;
|
||||
const totalDirs = detailedEntries.filter(e => e.isDirectory).length;
|
||||
const totalSize = detailedEntries.reduce((sum, entry) => sum + (entry.isDirectory ? 0 : entry.size), 0);
|
||||
|
||||
const summary = [
|
||||
"",
|
||||
`Total: ${totalFiles} files, ${totalDirs} directories`,
|
||||
`Combined size: ${formatSize(totalSize)}`
|
||||
];
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: [...formattedEntries, ...summary].join("\n")
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
case "directory_tree": {
|
||||
const parsed = DirectoryTreeArgsSchema.safeParse(args);
|
||||
if (!parsed.success) {
|
||||
@@ -635,12 +967,62 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Updates allowed directories based on MCP client roots
|
||||
async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) {
|
||||
const validatedRootDirs = await getValidRootDirectories(requestedRoots);
|
||||
if (validatedRootDirs.length > 0) {
|
||||
allowedDirectories = [...validatedRootDirs];
|
||||
console.error(`Updated allowed directories from MCP roots: ${validatedRootDirs.length} valid directories`);
|
||||
} else {
|
||||
console.error("No valid root directories provided by client");
|
||||
}
|
||||
}
|
||||
|
||||
// Handles dynamic roots updates during runtime, when client sends "roots/list_changed" notification, server fetches the updated roots and replaces all allowed directories with the new roots.
|
||||
server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
|
||||
try {
|
||||
// Request the updated roots list from the client
|
||||
const response = await server.listRoots();
|
||||
if (response && 'roots' in response) {
|
||||
await updateAllowedDirectoriesFromRoots(response.roots);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to request roots from client:", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
|
||||
// Handles post-initialization setup, specifically checking for and fetching MCP roots.
|
||||
server.oninitialized = async () => {
|
||||
const clientCapabilities = server.getClientCapabilities();
|
||||
|
||||
if (clientCapabilities?.roots) {
|
||||
try {
|
||||
const response = await server.listRoots();
|
||||
if (response && 'roots' in response) {
|
||||
await updateAllowedDirectoriesFromRoots(response.roots);
|
||||
} else {
|
||||
console.error("Client returned no roots set, keeping current settings");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to request initial roots from client:", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} else {
|
||||
if (allowedDirectories.length > 0) {
|
||||
console.error("Client does not support MCP Roots, using allowed directories set from server args:", allowedDirectories);
|
||||
}else{
|
||||
throw new Error(`Server cannot operate: No allowed directories available. Server was started without command-line directories and client either does not support MCP roots protocol or provided empty roots. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Start server
|
||||
async function runServer() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("Secure MCP Filesystem Server running on stdio");
|
||||
console.error("Allowed directories:", allowedDirectories);
|
||||
if (allowedDirectories.length === 0) {
|
||||
console.error("Started without allowed directories - waiting for client to provide roots via MCP protocol");
|
||||
}
|
||||
}
|
||||
|
||||
runServer().catch((error) => {
|
||||
|
||||
23
src/filesystem/jest.config.cjs
Normal file
23
src/filesystem/jest.config.cjs
Normal file
@@ -0,0 +1,23 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
useESM: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
collectCoverageFrom: [
|
||||
'**/*.ts',
|
||||
'!**/__tests__/**',
|
||||
'!**/dist/**',
|
||||
],
|
||||
}
|
||||
@@ -16,20 +16,26 @@
|
||||
"scripts": {
|
||||
"build": "tsc && shx chmod +x dist/*.js",
|
||||
"prepare": "npm run build",
|
||||
"watch": "tsc --watch"
|
||||
"watch": "tsc --watch",
|
||||
"test": "jest --config=jest.config.cjs --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "0.5.0",
|
||||
"@modelcontextprotocol/sdk": "^1.17.0",
|
||||
"diff": "^5.1.0",
|
||||
"glob": "^10.3.10",
|
||||
"minimatch": "^10.0.1",
|
||||
"zod-to-json-schema": "^3.23.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/diff": "^5.0.9",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/node": "^22",
|
||||
"jest": "^29.7.0",
|
||||
"shx": "^0.3.4",
|
||||
"typescript": "^5.3.3"
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
104
src/filesystem/path-utils.ts
Normal file
104
src/filesystem/path-utils.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import path from "path";
|
||||
import os from 'os';
|
||||
|
||||
/**
|
||||
* Converts WSL or Unix-style Windows paths to Windows format
|
||||
* @param p The path to convert
|
||||
* @returns Converted Windows path
|
||||
*/
|
||||
export function convertToWindowsPath(p: string): string {
|
||||
// Handle WSL paths (/mnt/c/...)
|
||||
if (p.startsWith('/mnt/')) {
|
||||
const driveLetter = p.charAt(5).toUpperCase();
|
||||
const pathPart = p.slice(6).replace(/\//g, '\\');
|
||||
return `${driveLetter}:${pathPart}`;
|
||||
}
|
||||
|
||||
// Handle Unix-style Windows paths (/c/...)
|
||||
if (p.match(/^\/[a-zA-Z]\//)) {
|
||||
const driveLetter = p.charAt(1).toUpperCase();
|
||||
const pathPart = p.slice(2).replace(/\//g, '\\');
|
||||
return `${driveLetter}:${pathPart}`;
|
||||
}
|
||||
|
||||
// Handle standard Windows paths, ensuring backslashes
|
||||
if (p.match(/^[a-zA-Z]:/)) {
|
||||
return p.replace(/\//g, '\\');
|
||||
}
|
||||
|
||||
// Leave non-Windows paths unchanged
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes path by standardizing format while preserving OS-specific behavior
|
||||
* @param p The path to normalize
|
||||
* @returns Normalized path
|
||||
*/
|
||||
export function normalizePath(p: string): string {
|
||||
// Remove any surrounding quotes and whitespace
|
||||
p = p.trim().replace(/^["']|["']$/g, '');
|
||||
|
||||
// Check if this is a Unix path (starts with / but not a Windows or WSL path)
|
||||
const isUnixPath = p.startsWith('/') &&
|
||||
!p.match(/^\/mnt\/[a-z]\//i) &&
|
||||
!p.match(/^\/[a-zA-Z]\//);
|
||||
|
||||
if (isUnixPath) {
|
||||
// For Unix paths, just normalize without converting to Windows format
|
||||
// Replace double slashes with single slashes and remove trailing slashes
|
||||
return p.replace(/\/+/g, '/').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
// Convert WSL or Unix-style Windows paths to Windows format
|
||||
p = convertToWindowsPath(p);
|
||||
|
||||
// Handle double backslashes, preserving leading UNC \\
|
||||
if (p.startsWith('\\\\')) {
|
||||
// For UNC paths, first normalize any excessive leading backslashes to exactly \\
|
||||
// Then normalize double backslashes in the rest of the path
|
||||
let uncPath = p;
|
||||
// Replace multiple leading backslashes with exactly two
|
||||
uncPath = uncPath.replace(/^\\{2,}/, '\\\\');
|
||||
// Now normalize any remaining double backslashes in the rest of the path
|
||||
const restOfPath = uncPath.substring(2).replace(/\\\\/g, '\\');
|
||||
p = '\\\\' + restOfPath;
|
||||
} else {
|
||||
// For non-UNC paths, normalize all double backslashes
|
||||
p = p.replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
// Use Node's path normalization, which handles . and .. segments
|
||||
let normalized = path.normalize(p);
|
||||
|
||||
// Fix UNC paths after normalization (path.normalize can remove a leading backslash)
|
||||
if (p.startsWith('\\\\') && !normalized.startsWith('\\\\')) {
|
||||
normalized = '\\' + normalized;
|
||||
}
|
||||
|
||||
// Handle Windows paths: convert slashes and ensure drive letter is capitalized
|
||||
if (normalized.match(/^[a-zA-Z]:/)) {
|
||||
let result = normalized.replace(/\//g, '\\');
|
||||
// Capitalize drive letter if present
|
||||
if (/^[a-z]:/.test(result)) {
|
||||
result = result.charAt(0).toUpperCase() + result.slice(1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// For all other paths (including relative paths), convert forward slashes to backslashes
|
||||
// This ensures relative paths like "some/relative/path" become "some\\relative\\path"
|
||||
return normalized.replace(/\//g, '\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands home directory tildes in paths
|
||||
* @param filepath The path to expand
|
||||
* @returns Expanded path
|
||||
*/
|
||||
export function expandHome(filepath: string): string {
|
||||
if (filepath.startsWith('~/') || filepath === '~') {
|
||||
return path.join(os.homedir(), filepath.slice(1));
|
||||
}
|
||||
return filepath;
|
||||
}
|
||||
77
src/filesystem/path-validation.ts
Normal file
77
src/filesystem/path-validation.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Checks if an absolute path is within any of the allowed directories.
|
||||
*
|
||||
* @param absolutePath - The absolute path to check (will be normalized)
|
||||
* @param allowedDirectories - Array of absolute allowed directory paths (will be normalized)
|
||||
* @returns true if the path is within an allowed directory, false otherwise
|
||||
* @throws Error if given relative paths after normalization
|
||||
*/
|
||||
export function isPathWithinAllowedDirectories(absolutePath: string, allowedDirectories: string[]): boolean {
|
||||
// Type validation
|
||||
if (typeof absolutePath !== 'string' || !Array.isArray(allowedDirectories)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject empty inputs
|
||||
if (!absolutePath || allowedDirectories.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject null bytes (forbidden in paths)
|
||||
if (absolutePath.includes('\x00')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize the input path
|
||||
let normalizedPath: string;
|
||||
try {
|
||||
normalizedPath = path.resolve(path.normalize(absolutePath));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify it's absolute after normalization
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
throw new Error('Path must be absolute after normalization');
|
||||
}
|
||||
|
||||
// Check against each allowed directory
|
||||
return allowedDirectories.some(dir => {
|
||||
if (typeof dir !== 'string' || !dir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject null bytes in allowed dirs
|
||||
if (dir.includes('\x00')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize the allowed directory
|
||||
let normalizedDir: string;
|
||||
try {
|
||||
normalizedDir = path.resolve(path.normalize(dir));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify allowed directory is absolute after normalization
|
||||
if (!path.isAbsolute(normalizedDir)) {
|
||||
throw new Error('Allowed directories must be absolute paths after normalization');
|
||||
}
|
||||
|
||||
// Check if normalizedPath is within normalizedDir
|
||||
// Path is inside if it's the same or a subdirectory
|
||||
if (normalizedPath === normalizedDir) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Special case for root directory to avoid double slash
|
||||
if (normalizedDir === path.sep) {
|
||||
return normalizedPath.startsWith(path.sep);
|
||||
}
|
||||
|
||||
return normalizedPath.startsWith(normalizedDir + path.sep);
|
||||
});
|
||||
}
|
||||
76
src/filesystem/roots-utils.ts
Normal file
76
src/filesystem/roots-utils.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { promises as fs, type Stats } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { normalizePath } from './path-utils.js';
|
||||
import type { Root } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
/**
|
||||
* Converts a root URI to a normalized directory path with basic security validation.
|
||||
* @param rootUri - File URI (file://...) or plain directory path
|
||||
* @returns Promise resolving to validated path or null if invalid
|
||||
*/
|
||||
async function parseRootUri(rootUri: string): Promise<string | null> {
|
||||
try {
|
||||
const rawPath = rootUri.startsWith('file://') ? rootUri.slice(7) : rootUri;
|
||||
const expandedPath = rawPath.startsWith('~/') || rawPath === '~'
|
||||
? path.join(os.homedir(), rawPath.slice(1))
|
||||
: rawPath;
|
||||
const absolutePath = path.resolve(expandedPath);
|
||||
const resolvedPath = await fs.realpath(absolutePath);
|
||||
return normalizePath(resolvedPath);
|
||||
} catch {
|
||||
return null; // Path doesn't exist or other error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats error message for directory validation failures.
|
||||
* @param dir - Directory path that failed validation
|
||||
* @param error - Error that occurred during validation
|
||||
* @param reason - Specific reason for failure
|
||||
* @returns Formatted error message
|
||||
*/
|
||||
function formatDirectoryError(dir: string, error?: unknown, reason?: string): string {
|
||||
if (reason) {
|
||||
return `Skipping ${reason}: ${dir}`;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `Skipping invalid directory: ${dir} due to error: ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves requested root directories from MCP root specifications.
|
||||
*
|
||||
* Converts root URI specifications (file:// URIs or plain paths) into normalized
|
||||
* directory paths, validating that each path exists and is a directory.
|
||||
* Includes symlink resolution for security.
|
||||
*
|
||||
* @param requestedRoots - Array of root specifications with URI and optional name
|
||||
* @returns Promise resolving to array of validated directory paths
|
||||
*/
|
||||
export async function getValidRootDirectories(
|
||||
requestedRoots: readonly Root[]
|
||||
): Promise<string[]> {
|
||||
const validatedDirectories: string[] = [];
|
||||
|
||||
for (const requestedRoot of requestedRoots) {
|
||||
const resolvedPath = await parseRootUri(requestedRoot.uri);
|
||||
if (!resolvedPath) {
|
||||
console.error(formatDirectoryError(requestedRoot.uri, undefined, 'invalid path or inaccessible'));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats: Stats = await fs.stat(resolvedPath);
|
||||
if (stats.isDirectory()) {
|
||||
validatedDirectories.push(resolvedPath);
|
||||
} else {
|
||||
console.error(formatDirectoryError(resolvedPath, undefined, 'non-directory root'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(formatDirectoryError(resolvedPath, error));
|
||||
}
|
||||
}
|
||||
|
||||
return validatedDirectories;
|
||||
}
|
||||
Reference in New Issue
Block a user