This is page 5 of 5. Use http://codebase.md/modelcontextprotocol/servers?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── pull_request_template.md │ └── workflows │ ├── claude.yml │ ├── python.yml │ ├── release.yml │ └── typescript.yml ├── .gitignore ├── .npmrc ├── .vscode │ └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── scripts │ └── release.py ├── SECURITY.md ├── src │ ├── everything │ │ ├── CLAUDE.md │ │ ├── Dockerfile │ │ ├── everything.ts │ │ ├── index.ts │ │ ├── instructions.md │ │ ├── package.json │ │ ├── README.md │ │ ├── sse.ts │ │ ├── stdio.ts │ │ ├── streamableHttp.ts │ │ └── tsconfig.json │ ├── fetch │ │ ├── .python-version │ │ ├── Dockerfile │ │ ├── LICENSE │ │ ├── pyproject.toml │ │ ├── README.md │ │ ├── src │ │ │ └── mcp_server_fetch │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ └── server.py │ │ └── uv.lock │ ├── filesystem │ │ ├── __tests__ │ │ │ ├── directory-tree.test.ts │ │ │ ├── lib.test.ts │ │ │ ├── path-utils.test.ts │ │ │ ├── path-validation.test.ts │ │ │ └── roots-utils.test.ts │ │ ├── Dockerfile │ │ ├── index.ts │ │ ├── jest.config.cjs │ │ ├── lib.ts │ │ ├── package.json │ │ ├── path-utils.ts │ │ ├── path-validation.ts │ │ ├── README.md │ │ ├── roots-utils.ts │ │ └── tsconfig.json │ ├── git │ │ ├── .gitignore │ │ ├── .python-version │ │ ├── Dockerfile │ │ ├── LICENSE │ │ ├── pyproject.toml │ │ ├── README.md │ │ ├── src │ │ │ └── mcp_server_git │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ ├── py.typed │ │ │ └── server.py │ │ ├── tests │ │ │ └── test_server.py │ │ └── uv.lock │ ├── memory │ │ ├── Dockerfile │ │ ├── index.ts │ │ ├── package.json │ │ ├── README.md │ │ └── tsconfig.json │ ├── sequentialthinking │ │ ├── Dockerfile │ │ ├── index.ts │ │ ├── package.json │ │ ├── README.md │ │ └── tsconfig.json │ └── time │ ├── .python-version │ ├── Dockerfile │ ├── pyproject.toml │ ├── README.md │ ├── src │ │ └── mcp_server_time │ │ ├── __init__.py │ │ ├── __main__.py │ │ └── server.py │ ├── test │ │ └── time_server_test.py │ └── uv.lock └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /src/filesystem/__tests__/path-validation.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; 2 | import * as path from 'path'; 3 | import * as fs from 'fs/promises'; 4 | import * as os from 'os'; 5 | import { isPathWithinAllowedDirectories } from '../path-validation.js'; 6 | 7 | /** 8 | * Check if the current environment supports symlink creation 9 | */ 10 | async function checkSymlinkSupport(): Promise<boolean> { 11 | const testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'symlink-test-')); 12 | try { 13 | const targetFile = path.join(testDir, 'target.txt'); 14 | const linkFile = path.join(testDir, 'link.txt'); 15 | 16 | await fs.writeFile(targetFile, 'test'); 17 | await fs.symlink(targetFile, linkFile); 18 | 19 | // If we get here, symlinks are supported 20 | return true; 21 | } catch (error) { 22 | // EPERM indicates no symlink permissions 23 | if ((error as NodeJS.ErrnoException).code === 'EPERM') { 24 | return false; 25 | } 26 | // Other errors might indicate a real problem 27 | throw error; 28 | } finally { 29 | await fs.rm(testDir, { recursive: true, force: true }); 30 | } 31 | } 32 | 33 | // Global variable to store symlink support status 34 | let symlinkSupported: boolean | null = null; 35 | 36 | /** 37 | * Get cached symlink support status, checking once per test run 38 | */ 39 | async function getSymlinkSupport(): Promise<boolean> { 40 | if (symlinkSupported === null) { 41 | symlinkSupported = await checkSymlinkSupport(); 42 | if (!symlinkSupported) { 43 | console.log('\n⚠️ Symlink tests will be skipped - symlink creation not supported in this environment'); 44 | console.log(' On Windows, enable Developer Mode or run as Administrator to enable symlink tests'); 45 | } 46 | } 47 | return symlinkSupported; 48 | } 49 | 50 | describe('Path Validation', () => { 51 | it('allows exact directory match', () => { 52 | const allowed = ['/home/user/project']; 53 | expect(isPathWithinAllowedDirectories('/home/user/project', allowed)).toBe(true); 54 | }); 55 | 56 | it('allows subdirectories', () => { 57 | const allowed = ['/home/user/project']; 58 | expect(isPathWithinAllowedDirectories('/home/user/project/src', allowed)).toBe(true); 59 | expect(isPathWithinAllowedDirectories('/home/user/project/src/index.js', allowed)).toBe(true); 60 | expect(isPathWithinAllowedDirectories('/home/user/project/deeply/nested/file.txt', allowed)).toBe(true); 61 | }); 62 | 63 | it('blocks similar directory names (prefix vulnerability)', () => { 64 | const allowed = ['/home/user/project']; 65 | expect(isPathWithinAllowedDirectories('/home/user/project2', allowed)).toBe(false); 66 | expect(isPathWithinAllowedDirectories('/home/user/project_backup', allowed)).toBe(false); 67 | expect(isPathWithinAllowedDirectories('/home/user/project-old', allowed)).toBe(false); 68 | expect(isPathWithinAllowedDirectories('/home/user/projectile', allowed)).toBe(false); 69 | expect(isPathWithinAllowedDirectories('/home/user/project.bak', allowed)).toBe(false); 70 | }); 71 | 72 | it('blocks paths outside allowed directories', () => { 73 | const allowed = ['/home/user/project']; 74 | expect(isPathWithinAllowedDirectories('/home/user/other', allowed)).toBe(false); 75 | expect(isPathWithinAllowedDirectories('/etc/passwd', allowed)).toBe(false); 76 | expect(isPathWithinAllowedDirectories('/home/user', allowed)).toBe(false); 77 | expect(isPathWithinAllowedDirectories('/', allowed)).toBe(false); 78 | }); 79 | 80 | it('handles multiple allowed directories', () => { 81 | const allowed = ['/home/user/project1', '/home/user/project2']; 82 | expect(isPathWithinAllowedDirectories('/home/user/project1/src', allowed)).toBe(true); 83 | expect(isPathWithinAllowedDirectories('/home/user/project2/src', allowed)).toBe(true); 84 | expect(isPathWithinAllowedDirectories('/home/user/project3', allowed)).toBe(false); 85 | expect(isPathWithinAllowedDirectories('/home/user/project1_backup', allowed)).toBe(false); 86 | expect(isPathWithinAllowedDirectories('/home/user/project2-old', allowed)).toBe(false); 87 | }); 88 | 89 | it('blocks parent and sibling directories', () => { 90 | const allowed = ['/test/allowed']; 91 | 92 | // Parent directory 93 | expect(isPathWithinAllowedDirectories('/test', allowed)).toBe(false); 94 | expect(isPathWithinAllowedDirectories('/', allowed)).toBe(false); 95 | 96 | // Sibling with common prefix 97 | expect(isPathWithinAllowedDirectories('/test/allowed_sibling', allowed)).toBe(false); 98 | expect(isPathWithinAllowedDirectories('/test/allowed2', allowed)).toBe(false); 99 | }); 100 | 101 | it('handles paths with special characters', () => { 102 | const allowed = ['/home/user/my-project (v2)']; 103 | 104 | expect(isPathWithinAllowedDirectories('/home/user/my-project (v2)', allowed)).toBe(true); 105 | expect(isPathWithinAllowedDirectories('/home/user/my-project (v2)/src', allowed)).toBe(true); 106 | expect(isPathWithinAllowedDirectories('/home/user/my-project (v2)_backup', allowed)).toBe(false); 107 | expect(isPathWithinAllowedDirectories('/home/user/my-project', allowed)).toBe(false); 108 | }); 109 | 110 | describe('Input validation', () => { 111 | it('rejects empty inputs', () => { 112 | const allowed = ['/home/user/project']; 113 | 114 | expect(isPathWithinAllowedDirectories('', allowed)).toBe(false); 115 | expect(isPathWithinAllowedDirectories('/home/user/project', [])).toBe(false); 116 | }); 117 | 118 | it('handles trailing separators correctly', () => { 119 | const allowed = ['/home/user/project']; 120 | 121 | // Path with trailing separator should still match 122 | expect(isPathWithinAllowedDirectories('/home/user/project/', allowed)).toBe(true); 123 | 124 | // Allowed directory with trailing separator 125 | const allowedWithSep = ['/home/user/project/']; 126 | expect(isPathWithinAllowedDirectories('/home/user/project', allowedWithSep)).toBe(true); 127 | expect(isPathWithinAllowedDirectories('/home/user/project/', allowedWithSep)).toBe(true); 128 | 129 | // Should still block similar names with or without trailing separators 130 | expect(isPathWithinAllowedDirectories('/home/user/project2', allowedWithSep)).toBe(false); 131 | expect(isPathWithinAllowedDirectories('/home/user/project2', allowed)).toBe(false); 132 | expect(isPathWithinAllowedDirectories('/home/user/project2/', allowed)).toBe(false); 133 | }); 134 | 135 | it('skips empty directory entries in allowed list', () => { 136 | const allowed = ['', '/home/user/project', '']; 137 | expect(isPathWithinAllowedDirectories('/home/user/project', allowed)).toBe(true); 138 | expect(isPathWithinAllowedDirectories('/home/user/project/src', allowed)).toBe(true); 139 | 140 | // Should still validate properly with empty entries 141 | expect(isPathWithinAllowedDirectories('/home/user/other', allowed)).toBe(false); 142 | }); 143 | 144 | it('handles Windows paths with trailing separators', () => { 145 | if (path.sep === '\\') { 146 | const allowed = ['C:\\Users\\project']; 147 | 148 | // Path with trailing separator 149 | expect(isPathWithinAllowedDirectories('C:\\Users\\project\\', allowed)).toBe(true); 150 | 151 | // Allowed with trailing separator 152 | const allowedWithSep = ['C:\\Users\\project\\']; 153 | expect(isPathWithinAllowedDirectories('C:\\Users\\project', allowedWithSep)).toBe(true); 154 | expect(isPathWithinAllowedDirectories('C:\\Users\\project\\', allowedWithSep)).toBe(true); 155 | 156 | // Should still block similar names 157 | expect(isPathWithinAllowedDirectories('C:\\Users\\project2\\', allowed)).toBe(false); 158 | } 159 | }); 160 | }); 161 | 162 | describe('Error handling', () => { 163 | it('normalizes relative paths to absolute', () => { 164 | const allowed = [process.cwd()]; 165 | 166 | // Relative paths get normalized to absolute paths based on cwd 167 | expect(isPathWithinAllowedDirectories('relative/path', allowed)).toBe(true); 168 | expect(isPathWithinAllowedDirectories('./file', allowed)).toBe(true); 169 | 170 | // Parent directory references that escape allowed directory 171 | const parentAllowed = ['/home/user/project']; 172 | expect(isPathWithinAllowedDirectories('../parent', parentAllowed)).toBe(false); 173 | }); 174 | 175 | it('returns false for relative paths in allowed directories', () => { 176 | const badAllowed = ['relative/path', '/some/other/absolute/path']; 177 | 178 | // Relative paths in allowed dirs are normalized to absolute based on cwd 179 | // The normalized 'relative/path' won't match our test path 180 | expect(isPathWithinAllowedDirectories('/some/other/absolute/path/file', badAllowed)).toBe(true); 181 | expect(isPathWithinAllowedDirectories('/absolute/path/file', badAllowed)).toBe(false); 182 | }); 183 | 184 | it('handles null and undefined inputs gracefully', () => { 185 | const allowed = ['/home/user/project']; 186 | 187 | // Should return false, not crash 188 | expect(isPathWithinAllowedDirectories(null as any, allowed)).toBe(false); 189 | expect(isPathWithinAllowedDirectories(undefined as any, allowed)).toBe(false); 190 | expect(isPathWithinAllowedDirectories('/path', null as any)).toBe(false); 191 | expect(isPathWithinAllowedDirectories('/path', undefined as any)).toBe(false); 192 | }); 193 | }); 194 | 195 | describe('Unicode and special characters', () => { 196 | it('handles unicode characters in paths', () => { 197 | const allowed = ['/home/user/café']; 198 | 199 | expect(isPathWithinAllowedDirectories('/home/user/café', allowed)).toBe(true); 200 | expect(isPathWithinAllowedDirectories('/home/user/café/file', allowed)).toBe(true); 201 | 202 | // Different unicode representation won't match (not normalized) 203 | const decomposed = '/home/user/cafe\u0301'; // e + combining accent 204 | expect(isPathWithinAllowedDirectories(decomposed, allowed)).toBe(false); 205 | }); 206 | 207 | it('handles paths with spaces correctly', () => { 208 | const allowed = ['/home/user/my project']; 209 | 210 | expect(isPathWithinAllowedDirectories('/home/user/my project', allowed)).toBe(true); 211 | expect(isPathWithinAllowedDirectories('/home/user/my project/file', allowed)).toBe(true); 212 | 213 | // Partial matches should fail 214 | expect(isPathWithinAllowedDirectories('/home/user/my', allowed)).toBe(false); 215 | expect(isPathWithinAllowedDirectories('/home/user/my proj', allowed)).toBe(false); 216 | }); 217 | }); 218 | 219 | describe('Overlapping allowed directories', () => { 220 | it('handles nested allowed directories correctly', () => { 221 | const allowed = ['/home', '/home/user', '/home/user/project']; 222 | 223 | // All paths under /home are allowed 224 | expect(isPathWithinAllowedDirectories('/home/anything', allowed)).toBe(true); 225 | expect(isPathWithinAllowedDirectories('/home/user/anything', allowed)).toBe(true); 226 | expect(isPathWithinAllowedDirectories('/home/user/project/anything', allowed)).toBe(true); 227 | 228 | // First match wins (most permissive) 229 | expect(isPathWithinAllowedDirectories('/home/other/deep/path', allowed)).toBe(true); 230 | }); 231 | 232 | it('handles root directory as allowed', () => { 233 | const allowed = ['/']; 234 | 235 | // Everything is allowed under root (dangerous configuration) 236 | expect(isPathWithinAllowedDirectories('/', allowed)).toBe(true); 237 | expect(isPathWithinAllowedDirectories('/any/path', allowed)).toBe(true); 238 | expect(isPathWithinAllowedDirectories('/etc/passwd', allowed)).toBe(true); 239 | expect(isPathWithinAllowedDirectories('/home/user/secret', allowed)).toBe(true); 240 | 241 | // But only on the same filesystem root 242 | if (path.sep === '\\') { 243 | expect(isPathWithinAllowedDirectories('D:\\other', ['/'])).toBe(false); 244 | } 245 | }); 246 | }); 247 | 248 | describe('Cross-platform behavior', () => { 249 | it('handles Windows-style paths on Windows', () => { 250 | if (path.sep === '\\') { 251 | const allowed = ['C:\\Users\\project']; 252 | expect(isPathWithinAllowedDirectories('C:\\Users\\project', allowed)).toBe(true); 253 | expect(isPathWithinAllowedDirectories('C:\\Users\\project\\src', allowed)).toBe(true); 254 | expect(isPathWithinAllowedDirectories('C:\\Users\\project2', allowed)).toBe(false); 255 | expect(isPathWithinAllowedDirectories('C:\\Users\\project_backup', allowed)).toBe(false); 256 | } 257 | }); 258 | 259 | it('handles Unix-style paths on Unix', () => { 260 | if (path.sep === '/') { 261 | const allowed = ['/home/user/project']; 262 | expect(isPathWithinAllowedDirectories('/home/user/project', allowed)).toBe(true); 263 | expect(isPathWithinAllowedDirectories('/home/user/project/src', allowed)).toBe(true); 264 | expect(isPathWithinAllowedDirectories('/home/user/project2', allowed)).toBe(false); 265 | } 266 | }); 267 | }); 268 | 269 | describe('Validation Tests - Path Traversal', () => { 270 | it('blocks path traversal attempts', () => { 271 | const allowed = ['/home/user/project']; 272 | 273 | // Basic traversal attempts 274 | expect(isPathWithinAllowedDirectories('/home/user/project/../../../etc/passwd', allowed)).toBe(false); 275 | expect(isPathWithinAllowedDirectories('/home/user/project/../../other', allowed)).toBe(false); 276 | expect(isPathWithinAllowedDirectories('/home/user/project/../project2', allowed)).toBe(false); 277 | 278 | // Mixed traversal with valid segments 279 | expect(isPathWithinAllowedDirectories('/home/user/project/src/../../project2', allowed)).toBe(false); 280 | expect(isPathWithinAllowedDirectories('/home/user/project/./../../other', allowed)).toBe(false); 281 | 282 | // Multiple traversal sequences 283 | expect(isPathWithinAllowedDirectories('/home/user/project/../project/../../../etc', allowed)).toBe(false); 284 | }); 285 | 286 | it('blocks traversal in allowed directories', () => { 287 | const allowed = ['/home/user/project/../safe']; 288 | 289 | // The allowed directory itself should be normalized and safe 290 | expect(isPathWithinAllowedDirectories('/home/user/safe/file', allowed)).toBe(true); 291 | expect(isPathWithinAllowedDirectories('/home/user/project/file', allowed)).toBe(false); 292 | }); 293 | 294 | it('handles complex traversal patterns', () => { 295 | const allowed = ['/home/user/project']; 296 | 297 | // Double dots in filenames (not traversal) - these normalize to paths within allowed dir 298 | expect(isPathWithinAllowedDirectories('/home/user/project/..test', allowed)).toBe(true); // Not traversal 299 | expect(isPathWithinAllowedDirectories('/home/user/project/test..', allowed)).toBe(true); // Not traversal 300 | expect(isPathWithinAllowedDirectories('/home/user/project/te..st', allowed)).toBe(true); // Not traversal 301 | 302 | // Actual traversal 303 | expect(isPathWithinAllowedDirectories('/home/user/project/../test', allowed)).toBe(false); // Is traversal - goes to /home/user/test 304 | 305 | // Edge case: /home/user/project/.. normalizes to /home/user (parent dir) 306 | expect(isPathWithinAllowedDirectories('/home/user/project/..', allowed)).toBe(false); // Goes to parent 307 | }); 308 | }); 309 | 310 | describe('Validation Tests - Null Bytes', () => { 311 | it('rejects paths with null bytes', () => { 312 | const allowed = ['/home/user/project']; 313 | 314 | expect(isPathWithinAllowedDirectories('/home/user/project\x00/etc/passwd', allowed)).toBe(false); 315 | expect(isPathWithinAllowedDirectories('/home/user/project/test\x00.txt', allowed)).toBe(false); 316 | expect(isPathWithinAllowedDirectories('\x00/home/user/project', allowed)).toBe(false); 317 | expect(isPathWithinAllowedDirectories('/home/user/project/\x00', allowed)).toBe(false); 318 | }); 319 | 320 | it('rejects allowed directories with null bytes', () => { 321 | const allowed = ['/home/user/project\x00']; 322 | 323 | expect(isPathWithinAllowedDirectories('/home/user/project', allowed)).toBe(false); 324 | expect(isPathWithinAllowedDirectories('/home/user/project/file', allowed)).toBe(false); 325 | }); 326 | }); 327 | 328 | describe('Validation Tests - Special Characters', () => { 329 | it('allows percent signs in filenames', () => { 330 | const allowed = ['/home/user/project']; 331 | 332 | // Percent is a valid filename character 333 | expect(isPathWithinAllowedDirectories('/home/user/project/report_50%.pdf', allowed)).toBe(true); 334 | expect(isPathWithinAllowedDirectories('/home/user/project/Q1_25%_growth', allowed)).toBe(true); 335 | expect(isPathWithinAllowedDirectories('/home/user/project/%41', allowed)).toBe(true); // File named %41 336 | 337 | // URL encoding is NOT decoded by path.normalize, so these are just odd filenames 338 | expect(isPathWithinAllowedDirectories('/home/user/project/%2e%2e', allowed)).toBe(true); // File named "%2e%2e" 339 | expect(isPathWithinAllowedDirectories('/home/user/project/file%20name', allowed)).toBe(true); // File with %20 in name 340 | }); 341 | 342 | it('handles percent signs in allowed directories', () => { 343 | const allowed = ['/home/user/project%20files']; 344 | 345 | // This is a directory literally named "project%20files" 346 | expect(isPathWithinAllowedDirectories('/home/user/project%20files/test', allowed)).toBe(true); 347 | expect(isPathWithinAllowedDirectories('/home/user/project files/test', allowed)).toBe(false); // Different dir 348 | }); 349 | }); 350 | 351 | describe('Path Normalization', () => { 352 | it('normalizes paths before comparison', () => { 353 | const allowed = ['/home/user/project']; 354 | 355 | // Trailing slashes 356 | expect(isPathWithinAllowedDirectories('/home/user/project/', allowed)).toBe(true); 357 | expect(isPathWithinAllowedDirectories('/home/user/project//', allowed)).toBe(true); 358 | expect(isPathWithinAllowedDirectories('/home/user/project///', allowed)).toBe(true); 359 | 360 | // Current directory references 361 | expect(isPathWithinAllowedDirectories('/home/user/project/./src', allowed)).toBe(true); 362 | expect(isPathWithinAllowedDirectories('/home/user/./project/src', allowed)).toBe(true); 363 | 364 | // Multiple slashes 365 | expect(isPathWithinAllowedDirectories('/home/user/project//src//file', allowed)).toBe(true); 366 | expect(isPathWithinAllowedDirectories('/home//user//project//src', allowed)).toBe(true); 367 | 368 | // Should still block outside paths 369 | expect(isPathWithinAllowedDirectories('/home/user//project2', allowed)).toBe(false); 370 | }); 371 | 372 | it('handles mixed separators correctly', () => { 373 | if (path.sep === '\\') { 374 | const allowed = ['C:\\Users\\project']; 375 | 376 | // Mixed separators should be normalized 377 | expect(isPathWithinAllowedDirectories('C:/Users/project', allowed)).toBe(true); 378 | expect(isPathWithinAllowedDirectories('C:\\Users/project\\src', allowed)).toBe(true); 379 | expect(isPathWithinAllowedDirectories('C:/Users\\project/src', allowed)).toBe(true); 380 | } 381 | }); 382 | }); 383 | 384 | describe('Edge Cases', () => { 385 | it('rejects non-string inputs safely', () => { 386 | const allowed = ['/home/user/project']; 387 | 388 | expect(isPathWithinAllowedDirectories(123 as any, allowed)).toBe(false); 389 | expect(isPathWithinAllowedDirectories({} as any, allowed)).toBe(false); 390 | expect(isPathWithinAllowedDirectories([] as any, allowed)).toBe(false); 391 | expect(isPathWithinAllowedDirectories(null as any, allowed)).toBe(false); 392 | expect(isPathWithinAllowedDirectories(undefined as any, allowed)).toBe(false); 393 | 394 | // Non-string in allowed directories 395 | expect(isPathWithinAllowedDirectories('/home/user/project', [123 as any])).toBe(false); 396 | expect(isPathWithinAllowedDirectories('/home/user/project', [{} as any])).toBe(false); 397 | }); 398 | 399 | it('handles very long paths', () => { 400 | const allowed = ['/home/user/project']; 401 | 402 | // Create a very long path that's still valid 403 | const longSubPath = 'a/'.repeat(1000) + 'file.txt'; 404 | expect(isPathWithinAllowedDirectories(`/home/user/project/${longSubPath}`, allowed)).toBe(true); 405 | 406 | // Very long path that escapes 407 | const escapePath = 'a/'.repeat(1000) + '../'.repeat(1001) + 'etc/passwd'; 408 | expect(isPathWithinAllowedDirectories(`/home/user/project/${escapePath}`, allowed)).toBe(false); 409 | }); 410 | }); 411 | 412 | describe('Additional Coverage', () => { 413 | it('handles allowed directories with traversal that normalizes safely', () => { 414 | // These allowed dirs contain traversal but normalize to valid paths 415 | const allowed = ['/home/user/../user/project']; 416 | 417 | // Should normalize to /home/user/project and work correctly 418 | expect(isPathWithinAllowedDirectories('/home/user/project/file', allowed)).toBe(true); 419 | expect(isPathWithinAllowedDirectories('/home/user/other', allowed)).toBe(false); 420 | }); 421 | 422 | it('handles symbolic dots in filenames', () => { 423 | const allowed = ['/home/user/project']; 424 | 425 | // Single and double dots as actual filenames (not traversal) 426 | expect(isPathWithinAllowedDirectories('/home/user/project/.', allowed)).toBe(true); 427 | expect(isPathWithinAllowedDirectories('/home/user/project/..', allowed)).toBe(false); // This normalizes to parent 428 | expect(isPathWithinAllowedDirectories('/home/user/project/...', allowed)).toBe(true); // Three dots is a valid filename 429 | expect(isPathWithinAllowedDirectories('/home/user/project/....', allowed)).toBe(true); // Four dots is a valid filename 430 | }); 431 | 432 | it('handles UNC paths on Windows', () => { 433 | if (path.sep === '\\') { 434 | const allowed = ['\\\\server\\share\\project']; 435 | 436 | expect(isPathWithinAllowedDirectories('\\\\server\\share\\project', allowed)).toBe(true); 437 | expect(isPathWithinAllowedDirectories('\\\\server\\share\\project\\file', allowed)).toBe(true); 438 | expect(isPathWithinAllowedDirectories('\\\\server\\share\\other', allowed)).toBe(false); 439 | expect(isPathWithinAllowedDirectories('\\\\other\\share\\project', allowed)).toBe(false); 440 | } 441 | }); 442 | }); 443 | 444 | describe('Symlink Tests', () => { 445 | let testDir: string; 446 | let allowedDir: string; 447 | let forbiddenDir: string; 448 | 449 | beforeEach(async () => { 450 | testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fs-error-test-')); 451 | allowedDir = path.join(testDir, 'allowed'); 452 | forbiddenDir = path.join(testDir, 'forbidden'); 453 | 454 | await fs.mkdir(allowedDir, { recursive: true }); 455 | await fs.mkdir(forbiddenDir, { recursive: true }); 456 | }); 457 | 458 | afterEach(async () => { 459 | await fs.rm(testDir, { recursive: true, force: true }); 460 | }); 461 | 462 | it('validates symlink handling', async () => { 463 | // Test with symlinks 464 | try { 465 | const linkPath = path.join(allowedDir, 'bad-link'); 466 | const targetPath = path.join(forbiddenDir, 'target.txt'); 467 | 468 | await fs.writeFile(targetPath, 'content'); 469 | await fs.symlink(targetPath, linkPath); 470 | 471 | // In real implementation, this would throw with the resolved path 472 | const realPath = await fs.realpath(linkPath); 473 | const allowed = [allowedDir]; 474 | 475 | // Symlink target should be outside allowed directory 476 | expect(isPathWithinAllowedDirectories(realPath, allowed)).toBe(false); 477 | } catch (error) { 478 | // Skip if no symlink permissions 479 | } 480 | }); 481 | 482 | it('handles non-existent paths correctly', async () => { 483 | const newFilePath = path.join(allowedDir, 'subdir', 'newfile.txt'); 484 | 485 | // Parent directory doesn't exist 486 | try { 487 | await fs.access(newFilePath); 488 | } catch (error) { 489 | expect((error as NodeJS.ErrnoException).code).toBe('ENOENT'); 490 | } 491 | 492 | // After creating parent, validation should work 493 | await fs.mkdir(path.dirname(newFilePath), { recursive: true }); 494 | const allowed = [allowedDir]; 495 | expect(isPathWithinAllowedDirectories(newFilePath, allowed)).toBe(true); 496 | }); 497 | 498 | // Test path resolution consistency for symlinked files 499 | it('validates symlinked files consistently between path and resolved forms', async () => { 500 | try { 501 | // Setup: Create target file in forbidden area 502 | const targetFile = path.join(forbiddenDir, 'target.txt'); 503 | await fs.writeFile(targetFile, 'TARGET_CONTENT'); 504 | 505 | // Create symlink inside allowed directory pointing to forbidden file 506 | const symlinkPath = path.join(allowedDir, 'link-to-target.txt'); 507 | await fs.symlink(targetFile, symlinkPath); 508 | 509 | // The symlink path itself passes validation (looks like it's in allowed dir) 510 | expect(isPathWithinAllowedDirectories(symlinkPath, [allowedDir])).toBe(true); 511 | 512 | // But the resolved path should fail validation 513 | const resolvedPath = await fs.realpath(symlinkPath); 514 | expect(isPathWithinAllowedDirectories(resolvedPath, [allowedDir])).toBe(false); 515 | 516 | // Verify the resolved path goes to the forbidden location (normalize both paths for macOS temp dirs) 517 | expect(await fs.realpath(resolvedPath)).toBe(await fs.realpath(targetFile)); 518 | } catch (error) { 519 | // Skip if no symlink permissions on the system 520 | if ((error as NodeJS.ErrnoException).code !== 'EPERM') { 521 | throw error; 522 | } 523 | } 524 | }); 525 | 526 | // Test allowed directory resolution behavior 527 | it('validates paths correctly when allowed directory is resolved from symlink', async () => { 528 | try { 529 | // Setup: Create the actual target directory with content 530 | const actualTargetDir = path.join(testDir, 'actual-target'); 531 | await fs.mkdir(actualTargetDir, { recursive: true }); 532 | const targetFile = path.join(actualTargetDir, 'file.txt'); 533 | await fs.writeFile(targetFile, 'FILE_CONTENT'); 534 | 535 | // Setup: Create symlink directory that points to target 536 | const symlinkDir = path.join(testDir, 'symlink-dir'); 537 | await fs.symlink(actualTargetDir, symlinkDir); 538 | 539 | // Simulate resolved allowed directory (what the server startup should do) 540 | const resolvedAllowedDir = await fs.realpath(symlinkDir); 541 | const resolvedTargetDir = await fs.realpath(actualTargetDir); 542 | expect(resolvedAllowedDir).toBe(resolvedTargetDir); 543 | 544 | // Test 1: File access through original symlink path should pass validation with resolved allowed dir 545 | const fileViaSymlink = path.join(symlinkDir, 'file.txt'); 546 | const resolvedFile = await fs.realpath(fileViaSymlink); 547 | expect(isPathWithinAllowedDirectories(resolvedFile, [resolvedAllowedDir])).toBe(true); 548 | 549 | // Test 2: File access through resolved path should also pass validation 550 | const fileViaResolved = path.join(resolvedTargetDir, 'file.txt'); 551 | expect(isPathWithinAllowedDirectories(fileViaResolved, [resolvedAllowedDir])).toBe(true); 552 | 553 | // Test 3: Demonstrate inconsistent behavior with unresolved allowed directories 554 | // If allowed dirs were not resolved (storing symlink paths instead): 555 | const unresolvedAllowedDirs = [symlinkDir]; 556 | // This validation would incorrectly fail for the same content: 557 | expect(isPathWithinAllowedDirectories(resolvedFile, unresolvedAllowedDirs)).toBe(false); 558 | 559 | } catch (error) { 560 | // Skip if no symlink permissions on the system 561 | if ((error as NodeJS.ErrnoException).code !== 'EPERM') { 562 | throw error; 563 | } 564 | } 565 | }); 566 | 567 | it('resolves nested symlink chains completely', async () => { 568 | try { 569 | // Setup: Create target file in forbidden area 570 | const actualTarget = path.join(forbiddenDir, 'target-file.txt'); 571 | await fs.writeFile(actualTarget, 'FINAL_CONTENT'); 572 | 573 | // Create chain of symlinks: allowedFile -> link2 -> link1 -> actualTarget 574 | const link1 = path.join(testDir, 'intermediate-link1'); 575 | const link2 = path.join(testDir, 'intermediate-link2'); 576 | const allowedFile = path.join(allowedDir, 'seemingly-safe-file'); 577 | 578 | await fs.symlink(actualTarget, link1); 579 | await fs.symlink(link1, link2); 580 | await fs.symlink(link2, allowedFile); 581 | 582 | // The allowed file path passes basic validation 583 | expect(isPathWithinAllowedDirectories(allowedFile, [allowedDir])).toBe(true); 584 | 585 | // But complete resolution reveals the forbidden target 586 | const fullyResolvedPath = await fs.realpath(allowedFile); 587 | expect(isPathWithinAllowedDirectories(fullyResolvedPath, [allowedDir])).toBe(false); 588 | expect(await fs.realpath(fullyResolvedPath)).toBe(await fs.realpath(actualTarget)); 589 | 590 | } catch (error) { 591 | // Skip if no symlink permissions on the system 592 | if ((error as NodeJS.ErrnoException).code !== 'EPERM') { 593 | throw error; 594 | } 595 | } 596 | }); 597 | }); 598 | 599 | describe('Path Validation Race Condition Tests', () => { 600 | let testDir: string; 601 | let allowedDir: string; 602 | let forbiddenDir: string; 603 | let targetFile: string; 604 | let testPath: string; 605 | 606 | beforeEach(async () => { 607 | testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'race-test-')); 608 | allowedDir = path.join(testDir, 'allowed'); 609 | forbiddenDir = path.join(testDir, 'outside'); 610 | targetFile = path.join(forbiddenDir, 'target.txt'); 611 | testPath = path.join(allowedDir, 'test.txt'); 612 | 613 | await fs.mkdir(allowedDir, { recursive: true }); 614 | await fs.mkdir(forbiddenDir, { recursive: true }); 615 | await fs.writeFile(targetFile, 'ORIGINAL CONTENT', 'utf-8'); 616 | }); 617 | 618 | afterEach(async () => { 619 | await fs.rm(testDir, { recursive: true, force: true }); 620 | }); 621 | 622 | it('validates non-existent file paths based on parent directory', async () => { 623 | const allowed = [allowedDir]; 624 | 625 | expect(isPathWithinAllowedDirectories(testPath, allowed)).toBe(true); 626 | await expect(fs.access(testPath)).rejects.toThrow(); 627 | 628 | const parentDir = path.dirname(testPath); 629 | expect(isPathWithinAllowedDirectories(parentDir, allowed)).toBe(true); 630 | }); 631 | 632 | it('demonstrates symlink race condition allows writing outside allowed directories', async () => { 633 | const symlinkSupported = await getSymlinkSupport(); 634 | if (!symlinkSupported) { 635 | console.log(' ⏭️ Skipping symlink race condition test - symlinks not supported'); 636 | return; 637 | } 638 | 639 | const allowed = [allowedDir]; 640 | 641 | await expect(fs.access(testPath)).rejects.toThrow(); 642 | expect(isPathWithinAllowedDirectories(testPath, allowed)).toBe(true); 643 | 644 | await fs.symlink(targetFile, testPath); 645 | await fs.writeFile(testPath, 'MODIFIED CONTENT', 'utf-8'); 646 | 647 | const targetContent = await fs.readFile(targetFile, 'utf-8'); 648 | expect(targetContent).toBe('MODIFIED CONTENT'); 649 | 650 | const resolvedPath = await fs.realpath(testPath); 651 | expect(isPathWithinAllowedDirectories(resolvedPath, allowed)).toBe(false); 652 | }); 653 | 654 | it('shows timing differences between validation approaches', async () => { 655 | const symlinkSupported = await getSymlinkSupport(); 656 | if (!symlinkSupported) { 657 | console.log(' ⏭️ Skipping timing validation test - symlinks not supported'); 658 | return; 659 | } 660 | 661 | const allowed = [allowedDir]; 662 | 663 | const validation1 = isPathWithinAllowedDirectories(testPath, allowed); 664 | expect(validation1).toBe(true); 665 | 666 | await fs.symlink(targetFile, testPath); 667 | 668 | const resolvedPath = await fs.realpath(testPath); 669 | const validation2 = isPathWithinAllowedDirectories(resolvedPath, allowed); 670 | expect(validation2).toBe(false); 671 | 672 | expect(validation1).not.toBe(validation2); 673 | }); 674 | 675 | it('validates directory creation timing', async () => { 676 | const symlinkSupported = await getSymlinkSupport(); 677 | if (!symlinkSupported) { 678 | console.log(' ⏭️ Skipping directory creation timing test - symlinks not supported'); 679 | return; 680 | } 681 | 682 | const allowed = [allowedDir]; 683 | const testDir = path.join(allowedDir, 'newdir'); 684 | 685 | expect(isPathWithinAllowedDirectories(testDir, allowed)).toBe(true); 686 | 687 | await fs.symlink(forbiddenDir, testDir); 688 | 689 | expect(isPathWithinAllowedDirectories(testDir, allowed)).toBe(true); 690 | 691 | const resolved = await fs.realpath(testDir); 692 | expect(isPathWithinAllowedDirectories(resolved, allowed)).toBe(false); 693 | }); 694 | 695 | it('demonstrates exclusive file creation behavior', async () => { 696 | const symlinkSupported = await getSymlinkSupport(); 697 | if (!symlinkSupported) { 698 | console.log(' ⏭️ Skipping exclusive file creation test - symlinks not supported'); 699 | return; 700 | } 701 | 702 | const allowed = [allowedDir]; 703 | 704 | await fs.symlink(targetFile, testPath); 705 | 706 | await expect(fs.open(testPath, 'wx')).rejects.toThrow(/EEXIST/); 707 | 708 | await fs.writeFile(testPath, 'NEW CONTENT', 'utf-8'); 709 | const targetContent = await fs.readFile(targetFile, 'utf-8'); 710 | expect(targetContent).toBe('NEW CONTENT'); 711 | }); 712 | 713 | it('should use resolved parent paths for non-existent files', async () => { 714 | const symlinkSupported = await getSymlinkSupport(); 715 | if (!symlinkSupported) { 716 | console.log(' ⏭️ Skipping resolved parent paths test - symlinks not supported'); 717 | return; 718 | } 719 | 720 | const allowed = [allowedDir]; 721 | 722 | const symlinkDir = path.join(allowedDir, 'link'); 723 | await fs.symlink(forbiddenDir, symlinkDir); 724 | 725 | const fileThroughSymlink = path.join(symlinkDir, 'newfile.txt'); 726 | 727 | expect(fileThroughSymlink.startsWith(allowedDir)).toBe(true); 728 | 729 | const parentDir = path.dirname(fileThroughSymlink); 730 | const resolvedParent = await fs.realpath(parentDir); 731 | expect(isPathWithinAllowedDirectories(resolvedParent, allowed)).toBe(false); 732 | 733 | const expectedSafePath = path.join(resolvedParent, path.basename(fileThroughSymlink)); 734 | expect(isPathWithinAllowedDirectories(expectedSafePath, allowed)).toBe(false); 735 | }); 736 | 737 | it('demonstrates parent directory symlink traversal', async () => { 738 | const symlinkSupported = await getSymlinkSupport(); 739 | if (!symlinkSupported) { 740 | console.log(' ⏭️ Skipping parent directory symlink traversal test - symlinks not supported'); 741 | return; 742 | } 743 | 744 | const allowed = [allowedDir]; 745 | const deepPath = path.join(allowedDir, 'sub1', 'sub2', 'file.txt'); 746 | 747 | expect(isPathWithinAllowedDirectories(deepPath, allowed)).toBe(true); 748 | 749 | const sub1Path = path.join(allowedDir, 'sub1'); 750 | await fs.symlink(forbiddenDir, sub1Path); 751 | 752 | await fs.mkdir(path.join(sub1Path, 'sub2'), { recursive: true }); 753 | await fs.writeFile(deepPath, 'CONTENT', 'utf-8'); 754 | 755 | const realPath = await fs.realpath(deepPath); 756 | const realAllowedDir = await fs.realpath(allowedDir); 757 | const realForbiddenDir = await fs.realpath(forbiddenDir); 758 | 759 | expect(realPath.startsWith(realAllowedDir)).toBe(false); 760 | expect(realPath.startsWith(realForbiddenDir)).toBe(true); 761 | }); 762 | 763 | it('should prevent race condition between validatePath and file operation', async () => { 764 | const symlinkSupported = await getSymlinkSupport(); 765 | if (!symlinkSupported) { 766 | console.log(' ⏭️ Skipping race condition prevention test - symlinks not supported'); 767 | return; 768 | } 769 | 770 | const allowed = [allowedDir]; 771 | const racePath = path.join(allowedDir, 'race-file.txt'); 772 | const targetFile = path.join(forbiddenDir, 'target.txt'); 773 | 774 | await fs.writeFile(targetFile, 'ORIGINAL CONTENT', 'utf-8'); 775 | 776 | // Path validation would pass (file doesn't exist, parent is in allowed dir) 777 | expect(await fs.access(racePath).then(() => false).catch(() => true)).toBe(true); 778 | expect(isPathWithinAllowedDirectories(racePath, allowed)).toBe(true); 779 | 780 | // Race condition: symlink created after validation but before write 781 | await fs.symlink(targetFile, racePath); 782 | 783 | // With exclusive write flag, write should fail on symlink 784 | await expect( 785 | fs.writeFile(racePath, 'NEW CONTENT', { encoding: 'utf-8', flag: 'wx' }) 786 | ).rejects.toThrow(/EEXIST/); 787 | 788 | // Verify content unchanged 789 | const targetContent = await fs.readFile(targetFile, 'utf-8'); 790 | expect(targetContent).toBe('ORIGINAL CONTENT'); 791 | 792 | // The symlink exists but write was blocked 793 | const actualWritePath = await fs.realpath(racePath); 794 | expect(actualWritePath).toBe(await fs.realpath(targetFile)); 795 | expect(isPathWithinAllowedDirectories(actualWritePath, allowed)).toBe(false); 796 | }); 797 | 798 | it('should allow overwrites to legitimate files within allowed directories', async () => { 799 | const allowed = [allowedDir]; 800 | const legitFile = path.join(allowedDir, 'legit-file.txt'); 801 | 802 | // Create a legitimate file 803 | await fs.writeFile(legitFile, 'ORIGINAL', 'utf-8'); 804 | 805 | // Opening with w should work for legitimate files 806 | const fd = await fs.open(legitFile, 'w'); 807 | try { 808 | await fd.write('UPDATED', 0, 'utf-8'); 809 | } finally { 810 | await fd.close(); 811 | } 812 | 813 | const content = await fs.readFile(legitFile, 'utf-8'); 814 | expect(content).toBe('UPDATED'); 815 | }); 816 | 817 | it('should handle symlinks that point within allowed directories', async () => { 818 | const symlinkSupported = await getSymlinkSupport(); 819 | if (!symlinkSupported) { 820 | console.log(' ⏭️ Skipping symlinks within allowed directories test - symlinks not supported'); 821 | return; 822 | } 823 | 824 | const allowed = [allowedDir]; 825 | const targetFile = path.join(allowedDir, 'target.txt'); 826 | const symlinkPath = path.join(allowedDir, 'symlink.txt'); 827 | 828 | // Create target file within allowed directory 829 | await fs.writeFile(targetFile, 'TARGET CONTENT', 'utf-8'); 830 | 831 | // Create symlink pointing to allowed file 832 | await fs.symlink(targetFile, symlinkPath); 833 | 834 | // Opening symlink with w follows it to the target 835 | const fd = await fs.open(symlinkPath, 'w'); 836 | try { 837 | await fd.write('UPDATED VIA SYMLINK', 0, 'utf-8'); 838 | } finally { 839 | await fd.close(); 840 | } 841 | 842 | // Both symlink and target should show updated content 843 | const symlinkContent = await fs.readFile(symlinkPath, 'utf-8'); 844 | const targetContent = await fs.readFile(targetFile, 'utf-8'); 845 | expect(symlinkContent).toBe('UPDATED VIA SYMLINK'); 846 | expect(targetContent).toBe('UPDATED VIA SYMLINK'); 847 | }); 848 | 849 | it('should prevent overwriting files through symlinks pointing outside allowed directories', async () => { 850 | const symlinkSupported = await getSymlinkSupport(); 851 | if (!symlinkSupported) { 852 | console.log(' ⏭️ Skipping symlink overwrite prevention test - symlinks not supported'); 853 | return; 854 | } 855 | 856 | const allowed = [allowedDir]; 857 | const legitFile = path.join(allowedDir, 'existing.txt'); 858 | const targetFile = path.join(forbiddenDir, 'target.txt'); 859 | 860 | // Create a legitimate file first 861 | await fs.writeFile(legitFile, 'LEGIT CONTENT', 'utf-8'); 862 | 863 | // Create target file in forbidden directory 864 | await fs.writeFile(targetFile, 'FORBIDDEN CONTENT', 'utf-8'); 865 | 866 | // Now replace the legitimate file with a symlink to forbidden location 867 | await fs.unlink(legitFile); 868 | await fs.symlink(targetFile, legitFile); 869 | 870 | // Simulate the server's validation logic 871 | const stats = await fs.lstat(legitFile); 872 | expect(stats.isSymbolicLink()).toBe(true); 873 | 874 | const realPath = await fs.realpath(legitFile); 875 | expect(isPathWithinAllowedDirectories(realPath, allowed)).toBe(false); 876 | 877 | // With atomic rename, symlinks are replaced not followed 878 | // So this test now demonstrates the protection 879 | 880 | // Verify content remains unchanged 881 | const targetContent = await fs.readFile(targetFile, 'utf-8'); 882 | expect(targetContent).toBe('FORBIDDEN CONTENT'); 883 | }); 884 | 885 | it('demonstrates race condition in read operations', async () => { 886 | const symlinkSupported = await getSymlinkSupport(); 887 | if (!symlinkSupported) { 888 | console.log(' ⏭️ Skipping race condition in read operations test - symlinks not supported'); 889 | return; 890 | } 891 | 892 | const allowed = [allowedDir]; 893 | const legitFile = path.join(allowedDir, 'readable.txt'); 894 | const secretFile = path.join(forbiddenDir, 'secret.txt'); 895 | 896 | // Create legitimate file 897 | await fs.writeFile(legitFile, 'PUBLIC CONTENT', 'utf-8'); 898 | 899 | // Create secret file in forbidden directory 900 | await fs.writeFile(secretFile, 'SECRET CONTENT', 'utf-8'); 901 | 902 | // Step 1: validatePath would pass for legitimate file 903 | expect(isPathWithinAllowedDirectories(legitFile, allowed)).toBe(true); 904 | 905 | // Step 2: Race condition - replace file with symlink after validation 906 | await fs.unlink(legitFile); 907 | await fs.symlink(secretFile, legitFile); 908 | 909 | // Step 3: Read operation follows symlink to forbidden location 910 | const content = await fs.readFile(legitFile, 'utf-8'); 911 | 912 | // This shows the vulnerability - we read forbidden content 913 | expect(content).toBe('SECRET CONTENT'); 914 | expect(isPathWithinAllowedDirectories(await fs.realpath(legitFile), allowed)).toBe(false); 915 | }); 916 | 917 | it('verifies rename does not follow symlinks', async () => { 918 | const symlinkSupported = await getSymlinkSupport(); 919 | if (!symlinkSupported) { 920 | console.log(' ⏭️ Skipping rename symlink test - symlinks not supported'); 921 | return; 922 | } 923 | 924 | const allowed = [allowedDir]; 925 | const tempFile = path.join(allowedDir, 'temp.txt'); 926 | const targetSymlink = path.join(allowedDir, 'target-symlink.txt'); 927 | const forbiddenTarget = path.join(forbiddenDir, 'forbidden-target.txt'); 928 | 929 | // Create forbidden target 930 | await fs.writeFile(forbiddenTarget, 'ORIGINAL CONTENT', 'utf-8'); 931 | 932 | // Create symlink pointing to forbidden location 933 | await fs.symlink(forbiddenTarget, targetSymlink); 934 | 935 | // Write temp file 936 | await fs.writeFile(tempFile, 'NEW CONTENT', 'utf-8'); 937 | 938 | // Rename temp file to symlink path 939 | await fs.rename(tempFile, targetSymlink); 940 | 941 | // Check what happened 942 | const symlinkExists = await fs.lstat(targetSymlink).then(() => true).catch(() => false); 943 | const isSymlink = symlinkExists && (await fs.lstat(targetSymlink)).isSymbolicLink(); 944 | const targetContent = await fs.readFile(targetSymlink, 'utf-8'); 945 | const forbiddenContent = await fs.readFile(forbiddenTarget, 'utf-8'); 946 | 947 | // Rename should replace the symlink with a regular file 948 | expect(isSymlink).toBe(false); 949 | expect(targetContent).toBe('NEW CONTENT'); 950 | expect(forbiddenContent).toBe('ORIGINAL CONTENT'); // Unchanged 951 | }); 952 | }); 953 | }); 954 | ```