-
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
Conversation
Add validatePathWithinRepo helper to ensure file paths resolve within the repository root directory. This hardens the commit_files tool by validating paths before file operations. Changes: - Add src/mcp/path-validation.ts with async path validation using realpath - Update commit_files to validate all paths before reading files - Prevent symlink-based path escapes by resolving real paths - Add comprehensive test coverage including symlink attack scenarios 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
| resolvedRoot = await realpath(repoRoot); | ||
| } catch { | ||
| throw new Error(`Repository root '${repoRoot}' 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: Time-of-Check-Time-of-Use (TOCTOU) Race Condition
The validation occurs here, but the actual file read happens later in github-file-ops-server.ts (lines 269, 305). Between validation and use, an attacker could replace a legitimate file with a symlink to a sensitive location.
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 O_NOFOLLOW flag:
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
| const validatedFiles = await Promise.all( | ||
| files.map(async (filePath) => { | ||
| const fullPath = await validatePathWithinRepo(filePath, REPO_DIR); | ||
| // Calculate the relative path for the git tree entry | ||
| // Use the original filePath (normalized) for the git path, not the symlink-resolved path | ||
| const normalizedPath = resolve(resolvedRepoDir, filePath); | ||
| const relativePath = normalizedPath.slice(resolvedRepoDir.length + 1); | ||
| return { fullPath, relativePath }; | ||
| }), | ||
| ); |
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 Issue
validatePathWithinRepo() calls realpath(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:
- O(N) redundant operations where N = number of files
- Each realpath involves kernel syscalls (~1-5ms each)
- For 50 files: ~50-250ms wasted
Recommendation: Resolve repo root once before the loop:
const resolvedRepoDir = resolve(REPO_DIR);
const resolvedRepoRoot = await realpath(REPO_DIR); // Once, outside the loop
const validatedFiles = await Promise.all(
files.map(async (filePath) => {
// Pass pre-resolved root to avoid redundant realpath calls
const fullPath = await validatePathWithinRepo(filePath, REPO_DIR, resolvedRepoRoot);
const normalizedPath = resolve(resolvedRepoDir, filePath);
const relativePath = normalizedPath.slice(resolvedRepoDir.length + 1);
return { fullPath, relativePath };
}),
);You'll need to update validatePathWithinRepo to accept an optional resolvedRepoRoot parameter.
|
|
||
| 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 { |
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.
Missing Test Coverage: Chained Symlinks
The tests validate single-hop symlinks, but don't cover chained symlinks that eventually escape:
repo/link1 → repo/link2 → repo/link3 → /outside/secret.txt
While realpath() should resolve this, there's no explicit test validation.
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 });
}
});| * @returns The resolved absolute path (with symlinks resolved) if valid | ||
| * @throws Error if the path resolves outside the repository root |
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.
Documentation Inaccuracy: Return Value Behavior
The JSDoc states symlinks are "resolved," but the implementation has two different behaviors:
- Existing files (line 30-31): Returns
resolvedPathwith symlinks fully resolved viarealpath() - Non-existent files (line 47): Returns
initialPathwith symlinks NOT resolved
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).
Comprehensive Code Review - PR #796I've completed a thorough review using specialized agents for security, performance, code quality, test coverage, and documentation. Here are the key findings: 🔴 Critical Issues1. TOCTOU Race Condition (Security)The path validation occurs separately from file operations, creating a race window where an attacker could replace a validated file with a symlink. See inline comment on 2. delete_files Tool Missing Path Validation (Security)The
Impact: Arbitrary file deletion outside repository boundaries. Recommendation: Apply the same 3. Performance: Redundant realpath() Calls
🟡 High Priority Issues4. Symlink File Mode Handling
Recommendation: Use 5. Missing Test Coverage
See inline comment on 📝 Documentation & Code Quality6. JSDoc InaccuracyThe return value documentation doesn't reflect that non-existent files return unresolved paths. See inline comment on 7. Complex Error HandlingNested try-catch blocks (lines 30-53) swallow original error context, making debugging difficult. Consider extracting to separate helper functions and preserving error details. ✅ Positive Aspects
📋 Recommended Action ItemsMust fix before merge:
Should fix: Nice to have: Let me know if you'd like me to elaborate on any of these findings or help implement the recommended fixes! |
Summary
validatePathWithinRepohelper to ensure file paths resolve within the repository rootcommit_filestool by validating paths before file operationsrealpathto resolve symlinks, preventing symlink-based path escapesChanges
src/mcp/path-validation.ts: New async path validation utilitysrc/mcp/github-file-ops-server.ts: Updated to use path validationtest/github-file-ops-path-validation.test.ts: Comprehensive test suiteTest plan
🤖 Generated with Claude Code