-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat: add path validation for commit_files MCP tool #796
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import { realpath } from "fs/promises"; | ||
| import { resolve, sep } from "path"; | ||
|
|
||
| /** | ||
| * Validates that a file path resolves within the repository root. | ||
| * Prevents path traversal attacks via "../" sequences and symlinks. | ||
| * @param filePath - The file path to validate (can be relative or absolute) | ||
| * @param repoRoot - The repository root directory | ||
| * @returns The resolved absolute path (with symlinks resolved) if valid | ||
| * @throws Error if the path resolves outside the repository root | ||
|
Comment on lines
+9
to
+10
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Documentation Inaccuracy: Return Value Behavior The JSDoc states symlinks are "resolved," but the implementation has two different behaviors:
Suggested fix: * @returns The resolved absolute path if valid. For existing files, symlinks are
* fully resolved to their targets. For non-existent files, returns the
* normalized path without symlink resolution (since realpath cannot resolve
* paths that don't exist yet). |
||
| */ | ||
| export async function validatePathWithinRepo( | ||
| filePath: string, | ||
| repoRoot: string, | ||
| ): Promise<string> { | ||
| // First resolve the path string (handles .. and . segments) | ||
| const initialPath = resolve(repoRoot, filePath); | ||
|
|
||
| // Resolve symlinks to get the real path | ||
| // This prevents symlink attacks where a link inside the repo points outside | ||
| let resolvedRoot: string; | ||
| let resolvedPath: string; | ||
|
|
||
| try { | ||
| resolvedRoot = await realpath(repoRoot); | ||
| } catch { | ||
| throw new Error(`Repository root '${repoRoot}' does not exist`); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CRITICAL: Time-of-Check-Time-of-Use (TOCTOU) Race Condition The validation occurs here, but the actual file read happens later in Attack scenario: // 1. Validation passes for legitimate file
await validatePathWithinRepo("src/config.js", repoRoot);
// 2. RACE WINDOW: Attacker replaces file
// rm src/config.js && ln -s /etc/passwd src/config.js
// 3. File operation reads /etc/passwd
const content = await readFile(fullPath, "utf-8");Recommendation: Use file descriptors with import { open, constants as fsConstants } from 'fs/promises';
// After validation, open without following symlinks
const fd = await open(fullPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
const content = await fd.readFile('utf-8');
await fd.close();CWE-367: Time-of-check Time-of-use Race Condition |
||
|
|
||
| try { | ||
| resolvedPath = await realpath(initialPath); | ||
| } catch { | ||
| // File doesn't exist yet - fall back to checking the parent directory | ||
| // This handles the case where we're creating a new file | ||
| const parentDir = resolve(initialPath, ".."); | ||
| try { | ||
| const resolvedParent = await realpath(parentDir); | ||
| if ( | ||
| resolvedParent !== resolvedRoot && | ||
| !resolvedParent.startsWith(resolvedRoot + sep) | ||
| ) { | ||
| throw new Error( | ||
| `Path '${filePath}' resolves outside the repository root`, | ||
| ); | ||
| } | ||
| // Parent is valid, return the initial path since file doesn't exist yet | ||
| return initialPath; | ||
| } catch { | ||
| throw new Error( | ||
| `Path '${filePath}' resolves outside the repository root`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // Path must be within repo root (or be the root itself) | ||
| if ( | ||
| resolvedPath !== resolvedRoot && | ||
| !resolvedPath.startsWith(resolvedRoot + sep) | ||
| ) { | ||
| throw new Error(`Path '${filePath}' resolves outside the repository root`); | ||
| } | ||
|
|
||
| return resolvedPath; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,214 @@ | ||
| import { describe, expect, it, beforeAll, afterAll } from "bun:test"; | ||
| import { validatePathWithinRepo } from "../src/mcp/path-validation"; | ||
| import { resolve } from "path"; | ||
| import { mkdir, writeFile, symlink, rm, realpath } from "fs/promises"; | ||
| import { tmpdir } from "os"; | ||
|
|
||
| describe("validatePathWithinRepo", () => { | ||
| // Use a real temp directory for tests that need filesystem access | ||
| let testDir: string; | ||
| let repoRoot: string; | ||
| let outsideDir: string; | ||
| // Real paths after symlink resolution (e.g., /tmp -> /private/tmp on macOS) | ||
| let realRepoRoot: string; | ||
|
|
||
| beforeAll(async () => { | ||
| // Create test directory structure | ||
| testDir = resolve(tmpdir(), `path-validation-test-${Date.now()}`); | ||
| repoRoot = resolve(testDir, "repo"); | ||
| outsideDir = resolve(testDir, "outside"); | ||
|
|
||
| await mkdir(repoRoot, { recursive: true }); | ||
| await mkdir(resolve(repoRoot, "src"), { recursive: true }); | ||
| await mkdir(outsideDir, { recursive: true }); | ||
|
|
||
| // Create test files | ||
| await writeFile(resolve(repoRoot, "file.txt"), "inside repo"); | ||
| await writeFile(resolve(repoRoot, "src", "main.js"), "console.log('hi')"); | ||
| await writeFile(resolve(outsideDir, "secret.txt"), "sensitive data"); | ||
|
|
||
| // Get real paths after symlink resolution | ||
| realRepoRoot = await realpath(repoRoot); | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| // Cleanup | ||
| await rm(testDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| describe("valid paths", () => { | ||
| it("should accept simple relative paths", async () => { | ||
| const result = await validatePathWithinRepo("file.txt", repoRoot); | ||
| expect(result).toBe(resolve(realRepoRoot, "file.txt")); | ||
| }); | ||
|
|
||
| it("should accept nested relative paths", async () => { | ||
| const result = await validatePathWithinRepo("src/main.js", repoRoot); | ||
| expect(result).toBe(resolve(realRepoRoot, "src/main.js")); | ||
| }); | ||
|
|
||
| it("should accept paths with single dot segments", async () => { | ||
| const result = await validatePathWithinRepo("./src/main.js", repoRoot); | ||
| expect(result).toBe(resolve(realRepoRoot, "src/main.js")); | ||
| }); | ||
|
|
||
| it("should accept paths that use .. but resolve inside repo", async () => { | ||
| // src/../file.txt resolves to file.txt which is still inside repo | ||
| const result = await validatePathWithinRepo("src/../file.txt", repoRoot); | ||
| expect(result).toBe(resolve(realRepoRoot, "file.txt")); | ||
| }); | ||
|
|
||
| it("should accept absolute paths within the repo root", async () => { | ||
| const absolutePath = resolve(repoRoot, "file.txt"); | ||
| const result = await validatePathWithinRepo(absolutePath, repoRoot); | ||
| expect(result).toBe(resolve(realRepoRoot, "file.txt")); | ||
| }); | ||
|
|
||
| it("should accept the repo root itself", async () => { | ||
| const result = await validatePathWithinRepo(".", repoRoot); | ||
| expect(result).toBe(realRepoRoot); | ||
| }); | ||
|
|
||
| it("should handle new files (non-existent) in valid directories", async () => { | ||
| const result = await validatePathWithinRepo("src/newfile.js", repoRoot); | ||
| // For non-existent files, we validate the parent but return the initial path | ||
| // (can't realpath a file that doesn't exist yet) | ||
| expect(result).toBe(resolve(repoRoot, "src/newfile.js")); | ||
| }); | ||
| }); | ||
|
|
||
| describe("path traversal attacks", () => { | ||
| it("should reject simple parent directory traversal", async () => { | ||
| await expect( | ||
| validatePathWithinRepo("../outside/secret.txt", repoRoot), | ||
| ).rejects.toThrow(/resolves outside the repository root/); | ||
| }); | ||
|
|
||
| it("should reject deeply nested parent directory traversal", async () => { | ||
| await expect( | ||
| validatePathWithinRepo("../../../etc/passwd", repoRoot), | ||
| ).rejects.toThrow(/resolves outside the repository root/); | ||
| }); | ||
|
|
||
| it("should reject traversal hidden within path", async () => { | ||
| await expect( | ||
| validatePathWithinRepo("src/../../outside/secret.txt", repoRoot), | ||
| ).rejects.toThrow(/resolves outside the repository root/); | ||
| }); | ||
|
|
||
| it("should reject traversal at the end of path", async () => { | ||
| await expect( | ||
| validatePathWithinRepo("src/../..", repoRoot), | ||
| ).rejects.toThrow(/resolves outside the repository root/); | ||
| }); | ||
|
|
||
| it("should reject absolute paths outside the repo root", async () => { | ||
| await expect( | ||
| validatePathWithinRepo("/etc/passwd", repoRoot), | ||
| ).rejects.toThrow(/resolves outside the repository root/); | ||
| }); | ||
|
|
||
| it("should reject absolute paths to sibling directories", async () => { | ||
| await expect( | ||
| validatePathWithinRepo(resolve(outsideDir, "secret.txt"), repoRoot), | ||
| ).rejects.toThrow(/resolves outside the repository root/); | ||
| }); | ||
| }); | ||
|
|
||
| describe("symlink attacks", () => { | ||
| it("should reject symlinks pointing outside the repo", async () => { | ||
| // Create a symlink inside the repo that points to a file outside | ||
| const symlinkPath = resolve(repoRoot, "evil-link"); | ||
| await symlink(resolve(outsideDir, "secret.txt"), symlinkPath); | ||
|
|
||
| try { | ||
| // The symlink path looks like it's inside the repo, but points outside | ||
| await expect( | ||
| validatePathWithinRepo("evil-link", repoRoot), | ||
| ).rejects.toThrow(/resolves outside the repository root/); | ||
| } finally { | ||
|
Comment on lines
+117
to
+129
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing Test Coverage: Chained Symlinks The tests validate single-hop symlinks, but don't cover chained symlinks that eventually escape: While Recommended test: it("should reject chained symlinks that escape the repo", async () => {
const link1 = resolve(repoRoot, "link1");
const link2 = resolve(repoRoot, "link2");
const link3 = resolve(repoRoot, "link3");
await symlink(link2, link1);
await symlink(link3, link2);
await symlink(resolve(outsideDir, "secret.txt"), link3);
try {
await expect(
validatePathWithinRepo("link1", repoRoot)
).rejects.toThrow(/resolves outside the repository root/);
} finally {
await rm(link1, { force: true });
await rm(link2, { force: true });
await rm(link3, { force: true });
}
}); |
||
| await rm(symlinkPath, { force: true }); | ||
| } | ||
| }); | ||
|
|
||
| it("should reject symlinks to parent directories", async () => { | ||
| // Create a symlink to the parent directory | ||
| const symlinkPath = resolve(repoRoot, "parent-link"); | ||
| await symlink(testDir, symlinkPath); | ||
|
|
||
| try { | ||
| await expect( | ||
| validatePathWithinRepo("parent-link/outside/secret.txt", repoRoot), | ||
| ).rejects.toThrow(/resolves outside the repository root/); | ||
| } finally { | ||
| await rm(symlinkPath, { force: true }); | ||
| } | ||
| }); | ||
|
|
||
| it("should accept symlinks that resolve within the repo", async () => { | ||
| // Create a symlink inside the repo that points to another file inside | ||
| const symlinkPath = resolve(repoRoot, "good-link"); | ||
| await symlink(resolve(repoRoot, "file.txt"), symlinkPath); | ||
|
|
||
| try { | ||
| const result = await validatePathWithinRepo("good-link", repoRoot); | ||
| // Should resolve to the actual file location | ||
| expect(result).toBe(resolve(realRepoRoot, "file.txt")); | ||
| } finally { | ||
| await rm(symlinkPath, { force: true }); | ||
| } | ||
| }); | ||
|
|
||
| it("should reject directory symlinks that escape the repo", async () => { | ||
| // Create a symlink to outside directory | ||
| const symlinkPath = resolve(repoRoot, "escape-dir"); | ||
| await symlink(outsideDir, symlinkPath); | ||
|
|
||
| try { | ||
| await expect( | ||
| validatePathWithinRepo("escape-dir/secret.txt", repoRoot), | ||
| ).rejects.toThrow(/resolves outside the repository root/); | ||
| } finally { | ||
| await rm(symlinkPath, { force: true }); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| describe("edge cases", () => { | ||
| it("should handle empty path (current directory)", async () => { | ||
| const result = await validatePathWithinRepo("", repoRoot); | ||
| expect(result).toBe(realRepoRoot); | ||
| }); | ||
|
|
||
| it("should handle paths with multiple consecutive slashes", async () => { | ||
| const result = await validatePathWithinRepo("src//main.js", repoRoot); | ||
| expect(result).toBe(resolve(realRepoRoot, "src/main.js")); | ||
| }); | ||
|
|
||
| it("should handle paths with trailing slashes", async () => { | ||
| const result = await validatePathWithinRepo("src/", repoRoot); | ||
| expect(result).toBe(resolve(realRepoRoot, "src")); | ||
| }); | ||
|
|
||
| it("should reject prefix attack (repo root as prefix but not parent)", async () => { | ||
| // Create a sibling directory with repo name as prefix | ||
| const evilDir = repoRoot + "-evil"; | ||
| await mkdir(evilDir, { recursive: true }); | ||
| await writeFile(resolve(evilDir, "file.txt"), "evil"); | ||
|
|
||
| try { | ||
| await expect( | ||
| validatePathWithinRepo(resolve(evilDir, "file.txt"), repoRoot), | ||
| ).rejects.toThrow(/resolves outside the repository root/); | ||
| } finally { | ||
| await rm(evilDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
|
|
||
| it("should throw error for non-existent repo root", async () => { | ||
| await expect( | ||
| validatePathWithinRepo("file.txt", "/nonexistent/repo"), | ||
| ).rejects.toThrow(/does not exist/); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CRITICAL: Redundant
realpath()Calls - Performance IssuevalidatePathWithinRepo()callsrealpath(REPO_DIR)for every file in this Promise.all loop. For a 50-file commit, this means 50 identical filesystem operations resolving the same directory.Performance impact:
Recommendation: Resolve repo root once before the loop:
You'll need to update
validatePathWithinRepoto accept an optionalresolvedRepoRootparameter.