Skip to main content
Glama

Knowledge Graph Memory Server

MIT License
52,555
68,825
  • Apple
  • Linux
path-validation.test.ts41.4 kB
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'; /** * Check if the current environment supports symlink creation */ async function checkSymlinkSupport(): Promise<boolean> { const testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'symlink-test-')); try { const targetFile = path.join(testDir, 'target.txt'); const linkFile = path.join(testDir, 'link.txt'); await fs.writeFile(targetFile, 'test'); await fs.symlink(targetFile, linkFile); // If we get here, symlinks are supported return true; } catch (error) { // EPERM indicates no symlink permissions if ((error as NodeJS.ErrnoException).code === 'EPERM') { return false; } // Other errors might indicate a real problem throw error; } finally { await fs.rm(testDir, { recursive: true, force: true }); } } // Global variable to store symlink support status let symlinkSupported: boolean | null = null; /** * Get cached symlink support status, checking once per test run */ async function getSymlinkSupport(): Promise<boolean> { if (symlinkSupported === null) { symlinkSupported = await checkSymlinkSupport(); if (!symlinkSupported) { console.log('\n⚠️ Symlink tests will be skipped - symlink creation not supported in this environment'); console.log(' On Windows, enable Developer Mode or run as Administrator to enable symlink tests'); } } return symlinkSupported; } 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 symlinkSupported = await getSymlinkSupport(); if (!symlinkSupported) { console.log(' ⏭️ Skipping symlink race condition test - symlinks not supported'); return; } 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 symlinkSupported = await getSymlinkSupport(); if (!symlinkSupported) { console.log(' ⏭️ Skipping timing validation test - symlinks not supported'); return; } 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 symlinkSupported = await getSymlinkSupport(); if (!symlinkSupported) { console.log(' ⏭️ Skipping directory creation timing test - symlinks not supported'); return; } 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 symlinkSupported = await getSymlinkSupport(); if (!symlinkSupported) { console.log(' ⏭️ Skipping exclusive file creation test - symlinks not supported'); return; } 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 symlinkSupported = await getSymlinkSupport(); if (!symlinkSupported) { console.log(' ⏭️ Skipping resolved parent paths test - symlinks not supported'); return; } 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 symlinkSupported = await getSymlinkSupport(); if (!symlinkSupported) { console.log(' ⏭️ Skipping parent directory symlink traversal test - symlinks not supported'); return; } 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 symlinkSupported = await getSymlinkSupport(); if (!symlinkSupported) { console.log(' ⏭️ Skipping race condition prevention test - symlinks not supported'); return; } 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 symlinkSupported = await getSymlinkSupport(); if (!symlinkSupported) { console.log(' ⏭️ Skipping symlinks within allowed directories test - symlinks not supported'); return; } 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 symlinkSupported = await getSymlinkSupport(); if (!symlinkSupported) { console.log(' ⏭️ Skipping symlink overwrite prevention test - symlinks not supported'); return; } 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 symlinkSupported = await getSymlinkSupport(); if (!symlinkSupported) { console.log(' ⏭️ Skipping race condition in read operations test - symlinks not supported'); return; } 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 symlinkSupported = await getSymlinkSupport(); if (!symlinkSupported) { console.log(' ⏭️ Skipping rename symlink test - symlinks not supported'); return; } 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 }); }); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/modelcontextprotocol/knowledge-graph-memory-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server