Skip to main content
Glama

WebDAV MCP Server

by masx200
path-validation.test.ts44.1 kB
import { afterEach, beforeEach, describe, expect, it } 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/masx200/mcp-webdav-server'

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