Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 22 additions & 17 deletions src/mcp/github-file-ops-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readFile, stat } from "fs/promises";
import { join } from "path";
import { resolve } from "path";
import { constants } from "fs";
import fetch from "node-fetch";
import { GITHUB_API_URL } from "../github/api/config";
import { retryWithBackoff } from "../utils/retry";
import { validatePathWithinRepo } from "./path-validation";

type GitHubRef = {
object: {
Expand Down Expand Up @@ -213,12 +214,18 @@ server.tool(
throw new Error("GITHUB_TOKEN environment variable is required");
}

const processedFiles = files.map((filePath) => {
if (filePath.startsWith("/")) {
return filePath.slice(1);
}
return filePath;
});
// Validate all paths are within repository root and get full/relative paths
const resolvedRepoDir = resolve(REPO_DIR);
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 };
}),
);
Comment on lines +219 to +228
Copy link
Contributor

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.


// 1. Get the branch reference (create if doesn't exist)
const baseSha = await getOrCreateBranchRef(
Expand Down Expand Up @@ -247,18 +254,14 @@ server.tool(

// 3. Create tree entries for all files
const treeEntries = await Promise.all(
processedFiles.map(async (filePath) => {
const fullPath = filePath.startsWith("/")
? filePath
: join(REPO_DIR, filePath);

validatedFiles.map(async ({ fullPath, relativePath }) => {
// Get the proper file mode based on file permissions
const fileMode = await getFileMode(fullPath);

// Check if file is binary (images, etc.)
const isBinaryFile =
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
filePath,
relativePath,
);

if (isBinaryFile) {
Expand All @@ -284,15 +287,15 @@ server.tool(
if (!blobResponse.ok) {
const errorText = await blobResponse.text();
throw new Error(
`Failed to create blob for ${filePath}: ${blobResponse.status} - ${errorText}`,
`Failed to create blob for ${relativePath}: ${blobResponse.status} - ${errorText}`,
);
}

const blobData = (await blobResponse.json()) as { sha: string };

// Return tree entry with blob SHA
return {
path: filePath,
path: relativePath,
mode: fileMode,
type: "blob",
sha: blobData.sha,
Expand All @@ -301,7 +304,7 @@ server.tool(
// For text files, include content directly in tree
const content = await readFile(fullPath, "utf-8");
return {
path: filePath,
path: relativePath,
mode: fileMode,
type: "blob",
content: content,
Expand Down Expand Up @@ -421,7 +424,9 @@ server.tool(
author: newCommitData.author.name,
date: newCommitData.author.date,
},
files: processedFiles.map((path) => ({ path })),
files: validatedFiles.map(({ relativePath }) => ({
path: relativePath,
})),
tree: {
sha: treeData.sha,
},
Expand Down
64 changes: 64 additions & 0 deletions src/mcp/path-validation.ts
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
Copy link
Contributor

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 resolvedPath with symlinks fully resolved via realpath()
  • Non-existent files (line 47): Returns initialPath with 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).

*/
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`);
}
Copy link
Contributor

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


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;
}
214 changes: 214 additions & 0 deletions test/github-file-ops-path-validation.test.ts
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
Copy link
Contributor

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 });
  }
});

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/);
});
});
});
Loading