feat: 实现 MCP 工具集中式路径验证,增强安全性和可配置性

- 新增 path-validator.ts:参考 MCP filesystem 服务器设计的集中式路径验证器
  - 支持 CCW_PROJECT_ROOT 和 CCW_ALLOWED_DIRS 环境变量配置
  - 多层路径验证:绝对路径解析 → 沙箱检查 → 符号链接验证
  - 向后兼容:未设置环境变量时回退到 process.cwd()

- 更新所有 MCP 工具使用集中式路径验证:
  - write-file.ts: 使用 validatePath()
  - edit-file.ts: 使用 validatePath({ mustExist: true })
  - read-file.ts: 使用 validatePath() + getProjectRoot()
  - smart-search.ts: 使用 getProjectRoot()
  - core-memory.ts: 使用 getProjectRoot()

- MCP 服务器启动时输出项目根目录和允许目录信息

- MCP 管理界面增强:
  - CCW Tools 安装卡片新增路径设置 UI
  - 支持 CCW_PROJECT_ROOT 和 CCW_ALLOWED_DIRS 配置
  - 添加"使用当前项目"快捷按钮
  - 支持 Claude 和 Codex 两种模式
  - 添加中英文国际化翻译

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
catlog22
2025-12-21 18:14:06 +08:00
parent f492f4839a
commit 45f92fe066
11 changed files with 330 additions and 16 deletions

View File

@@ -0,0 +1,153 @@
/**
* Centralized Path Validation Utility
*
* Provides secure path validation and resolution for MCP tools.
* Prevents path traversal attacks and ensures operations stay within allowed directories.
*
* Inspired by MCP filesystem server's security model.
*/
import { resolve, isAbsolute, normalize, relative } from 'path';
import { realpath, access } from 'fs/promises';
import { constants } from 'fs';
// Environment variable configuration
const ENV_PROJECT_ROOT = 'CCW_PROJECT_ROOT';
const ENV_ALLOWED_DIRS = 'CCW_ALLOWED_DIRS';
/**
* Get project root directory
* Priority: CCW_PROJECT_ROOT > process.cwd()
*/
export function getProjectRoot(): string {
return process.env[ENV_PROJECT_ROOT] || process.cwd();
}
/**
* Get allowed directories list
* Priority: CCW_ALLOWED_DIRS > [getProjectRoot()]
*/
export function getAllowedDirectories(): string[] {
const envDirs = process.env[ENV_ALLOWED_DIRS];
if (envDirs) {
return envDirs.split(',').map(d => d.trim()).filter(Boolean);
}
return [getProjectRoot()];
}
/**
* Normalize path (unify separators to forward slash)
*/
export function normalizePath(p: string): string {
return normalize(p).replace(/\\/g, '/');
}
/**
* Check if path is within allowed directories
*/
export function isPathWithinAllowedDirectories(
targetPath: string,
allowedDirectories: string[]
): boolean {
const normalizedTarget = normalizePath(targetPath);
return allowedDirectories.some(dir => {
const normalizedDir = normalizePath(dir);
// Check if path equals or starts with allowed directory
return normalizedTarget === normalizedDir ||
normalizedTarget.startsWith(normalizedDir + '/');
});
}
/**
* Validate and resolve path (core function)
*
* Security model:
* 1. Resolve to absolute path
* 2. Check against allowed directories
* 3. Resolve symlinks and re-verify
*
* @param filePath - Path to validate
* @param options - Validation options
* @returns Validated absolute path
* @throws Error if path is outside allowed directories or validation fails
*/
export async function validatePath(
filePath: string,
options: {
allowedDirectories?: string[];
mustExist?: boolean;
} = {}
): Promise<string> {
const allowedDirs = options.allowedDirectories || getAllowedDirectories();
// 1. Resolve to absolute path
const absolutePath = isAbsolute(filePath)
? filePath
: resolve(getProjectRoot(), filePath);
const normalizedPath = normalizePath(absolutePath);
// 2. Initial sandbox check
if (!isPathWithinAllowedDirectories(normalizedPath, allowedDirs)) {
throw new Error(
`Access denied: path "${normalizedPath}" is outside allowed directories. ` +
`Allowed: [${allowedDirs.join(', ')}]`
);
}
// 3. Try to resolve symlinks and re-verify
try {
const realPath = await realpath(absolutePath);
const normalizedReal = normalizePath(realPath);
if (!isPathWithinAllowedDirectories(normalizedReal, allowedDirs)) {
throw new Error(
`Access denied: symlink target "${normalizedReal}" is outside allowed directories`
);
}
return normalizedReal;
} catch (error: any) {
// File doesn't exist - validate parent directory
if (error.code === 'ENOENT') {
if (options.mustExist) {
throw new Error(`File not found: ${absolutePath}`);
}
// Validate parent directory's real path
const parentDir = resolve(absolutePath, '..');
try {
const realParent = await realpath(parentDir);
const normalizedParent = normalizePath(realParent);
if (!isPathWithinAllowedDirectories(normalizedParent, allowedDirs)) {
throw new Error(
`Access denied: parent directory "${normalizedParent}" is outside allowed directories`
);
}
} catch (parentError: any) {
if (parentError.code === 'ENOENT') {
// Parent directory doesn't exist either - return original absolute path
// Let the caller create it if needed
return absolutePath;
}
throw parentError;
}
return absolutePath;
}
// Re-throw access denied errors
if (error.message?.includes('Access denied')) {
throw error;
}
throw error;
}
}
/**
* Resolve project-relative path (simplified, no strict validation)
* Use for cases where strict security validation is not needed
*/
export function resolveProjectPath(...pathSegments: string[]): string {
return resolve(getProjectRoot(), ...pathSegments);
}