Compare commits

..

1 Commits

Author SHA1 Message Date
Claude
80591ffc11 fix: prevent path traversal in delete_files MCP tool
The delete_files tool only validated absolute paths starting with "/",
allowing relative paths with traversal sequences like "./../../sensitive"
to bypass validation.

Now all paths are normalized using path.resolve() before validation,
ensuring both absolute and relative paths with ".." sequences are
properly blocked from accessing files outside the repository root.
2025-12-12 00:14:12 +00:00
5 changed files with 13 additions and 91 deletions

View File

@@ -2,7 +2,6 @@
import { readFileSync, existsSync } from "fs"; import { readFileSync, existsSync } from "fs";
import { exit } from "process"; import { exit } from "process";
import { stripAnsiCodes } from "../github/utils/sanitizer";
export type ToolUse = { export type ToolUse = {
type: string; type: string;
@@ -173,9 +172,6 @@ export function formatResultContent(content: any): string {
contentStr = String(content).trim(); contentStr = String(content).trim();
} }
// Strip ANSI escape codes from terminal output
contentStr = stripAnsiCodes(contentStr);
// Truncate very long results // Truncate very long results
if (contentStr.length > 3000) { if (contentStr.length > 3000) {
contentStr = contentStr.substring(0, 2997) + "..."; contentStr = contentStr.substring(0, 2997) + "...";

View File

@@ -1,11 +1,3 @@
export function stripAnsiCodes(content: string): string {
// Matches ANSI escape sequences:
// - \x1B[ (CSI) followed by parameters and a final byte
// - \x1B followed by single-character sequences
// Common sequences: \x1B[1;33m (colors), \x1B[0m (reset), \x1B[K (clear line)
return content.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
}
export function stripInvisibleCharacters(content: string): string { export function stripInvisibleCharacters(content: string): string {
content = content.replace(/[\u200B\u200C\u200D\uFEFF]/g, ""); content = content.replace(/[\u200B\u200C\u200D\uFEFF]/g, "");
content = content.replace( content = content.replace(

View File

@@ -4,7 +4,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod"; import { z } from "zod";
import { readFile, stat } from "fs/promises"; import { readFile, stat } from "fs/promises";
import { join } from "path"; import { join, resolve, sep } from "path";
import { constants } from "fs"; import { constants } from "fs";
import fetch from "node-fetch"; import fetch from "node-fetch";
import { GITHUB_API_URL } from "../github/api/config"; import { GITHUB_API_URL } from "../github/api/config";
@@ -474,20 +474,21 @@ server.tool(
throw new Error("GITHUB_TOKEN environment variable is required"); throw new Error("GITHUB_TOKEN environment variable is required");
} }
// Convert absolute paths to relative if they match CWD // Normalize all paths and validate they're within the repository root
const cwd = process.cwd(); const cwd = process.cwd();
const processedPaths = paths.map((filePath) => { const processedPaths = paths.map((filePath) => {
if (filePath.startsWith("/")) { // Normalize the path to resolve any .. or . sequences
if (filePath.startsWith(cwd)) { const normalizedPath = resolve(cwd, filePath);
// Strip CWD from absolute path
return filePath.slice(cwd.length + 1); // Validate the normalized path is within the current working directory
} else { if (!normalizedPath.startsWith(cwd + sep)) {
throw new Error( throw new Error(
`Path '${filePath}' must be relative to repository root or within current working directory`, `Path '${filePath}' resolves outside the repository root`,
); );
}
} }
return filePath;
// Convert to relative path by stripping the cwd prefix
return normalizedPath.slice(cwd.length + 1);
}); });
// 1. Get the branch reference (create if doesn't exist) // 1. Get the branch reference (create if doesn't exist)

View File

@@ -111,27 +111,6 @@ describe("formatResultContent", () => {
const result = formatResultContent(JSON.stringify(structuredContent)); const result = formatResultContent(JSON.stringify(structuredContent));
expect(result).toBe("**→** Hello world\n\n"); expect(result).toBe("**→** Hello world\n\n");
}); });
test("strips ANSI color codes from terminal output", () => {
// Test bold yellow warning (the issue reported: [1;33m)
const coloredOutput = "\x1B[1;33mWarning: something happened\x1B[0m";
const result = formatResultContent(coloredOutput);
expect(result).toBe("**→** Warning: something happened\n\n");
expect(result).not.toContain("\x1B");
expect(result).not.toContain("[1;33m");
});
test("strips ANSI codes from longer output in code blocks", () => {
const longColoredOutput =
"\x1B[32m✓\x1B[0m Test 1 passed\n" +
"\x1B[32m✓\x1B[0m Test 2 passed\n" +
"\x1B[31m✗\x1B[0m Test 3 failed\n" +
"Some additional output to make it longer";
const result = formatResultContent(longColoredOutput);
expect(result).toContain("✓ Test 1 passed");
expect(result).toContain("✗ Test 3 failed");
expect(result).not.toContain("\x1B");
});
}); });
describe("formatToolWithResult", () => { describe("formatToolWithResult", () => {

View File

@@ -1,6 +1,5 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { import {
stripAnsiCodes,
stripInvisibleCharacters, stripInvisibleCharacters,
stripMarkdownImageAltText, stripMarkdownImageAltText,
stripMarkdownLinkTitles, stripMarkdownLinkTitles,
@@ -11,51 +10,6 @@ import {
redactGitHubTokens, redactGitHubTokens,
} from "../src/github/utils/sanitizer"; } from "../src/github/utils/sanitizer";
describe("stripAnsiCodes", () => {
it("should remove color codes", () => {
// Bold yellow text: \x1B[1;33m
expect(stripAnsiCodes("\x1B[1;33mWarning\x1B[0m")).toBe("Warning");
// Red text: \x1B[31m
expect(stripAnsiCodes("\x1B[31mError\x1B[0m")).toBe("Error");
// Green text: \x1B[32m
expect(stripAnsiCodes("\x1B[32mSuccess\x1B[0m")).toBe("Success");
});
it("should remove bold and other style codes", () => {
// Bold: \x1B[1m
expect(stripAnsiCodes("\x1B[1mBold text\x1B[0m")).toBe("Bold text");
// Underline: \x1B[4m
expect(stripAnsiCodes("\x1B[4mUnderlined\x1B[0m")).toBe("Underlined");
});
it("should remove cursor movement codes", () => {
// Clear line: \x1B[K
expect(stripAnsiCodes("Text\x1B[K")).toBe("Text");
// Cursor up: \x1B[A
expect(stripAnsiCodes("Line1\x1B[ALine2")).toBe("Line1Line2");
});
it("should handle multiple ANSI codes in one string", () => {
const input = "\x1B[1;31mError:\x1B[0m \x1B[33mWarning\x1B[0m text";
expect(stripAnsiCodes(input)).toBe("Error: Warning text");
});
it("should preserve text without ANSI codes", () => {
expect(stripAnsiCodes("Normal text")).toBe("Normal text");
expect(stripAnsiCodes("Text with [brackets]")).toBe("Text with [brackets]");
});
it("should handle empty string", () => {
expect(stripAnsiCodes("")).toBe("");
});
it("should handle complex terminal output", () => {
// Simulates npm/yarn output with colors
const input = "\x1B[2K\x1B[1G\x1B[32m✓\x1B[0m Tests passed";
expect(stripAnsiCodes(input)).toBe("✓ Tests passed");
});
});
describe("stripInvisibleCharacters", () => { describe("stripInvisibleCharacters", () => {
it("should remove zero-width characters", () => { it("should remove zero-width characters", () => {
expect(stripInvisibleCharacters("Hello\u200BWorld")).toBe("HelloWorld"); expect(stripInvisibleCharacters("Hello\u200BWorld")).toBe("HelloWorld");