mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
- 在 path-validator.ts 中添加 isSandboxDisabled() 函数 - 修改 validatePath() 在沙箱禁用时跳过路径限制检查 - MCP server 启动日志显示沙箱状态 - /api/mcp-install-ccw API 支持 disableSandbox 参数 - Dashboard UI 添加禁用沙箱的复选框选项 - 添加中英文 i18n 翻译支持
188 lines
5.6 KiB
TypeScript
188 lines
5.6 KiB
TypeScript
/**
|
|
* 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, sep } 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';
|
|
const ENV_DISABLE_SANDBOX = 'CCW_DISABLE_SANDBOX';
|
|
|
|
/**
|
|
* Check if sandbox mode is disabled
|
|
* When disabled, path validation allows access to any directory
|
|
*/
|
|
export function isSandboxDisabled(): boolean {
|
|
const value = process.env[ENV_DISABLE_SANDBOX];
|
|
return value === '1' || value?.toLowerCase() === 'true';
|
|
}
|
|
|
|
/**
|
|
* 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, '/');
|
|
}
|
|
|
|
function canonicalizeForComparison(p: string): string {
|
|
const base = getProjectRoot();
|
|
const absolute = isAbsolute(p) ? p : resolve(base, p);
|
|
let canonical = normalize(absolute);
|
|
|
|
// Remove trailing separators (except drive roots like C:\ and posix root /)
|
|
canonical = canonical.replace(/[\\/]+$/, '');
|
|
if (/^[a-zA-Z]:$/.test(canonical)) {
|
|
canonical += sep;
|
|
} else if (canonical === '') {
|
|
canonical = sep;
|
|
}
|
|
|
|
// Windows paths are case-insensitive.
|
|
if (process.platform === 'win32') {
|
|
canonical = canonical.toLowerCase();
|
|
}
|
|
|
|
return canonical;
|
|
}
|
|
|
|
/**
|
|
* Check if path is within allowed directories
|
|
*/
|
|
export function isPathWithinAllowedDirectories(
|
|
targetPath: string,
|
|
allowedDirectories: string[]
|
|
): boolean {
|
|
const canonicalTarget = canonicalizeForComparison(targetPath);
|
|
return allowedDirectories.some(dir => {
|
|
const canonicalDir = canonicalizeForComparison(dir);
|
|
if (canonicalTarget === canonicalDir) return true;
|
|
|
|
const boundary = canonicalDir.endsWith(sep) ? canonicalDir : canonicalDir + sep;
|
|
return canonicalTarget.startsWith(boundary);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 sandboxDisabled = isSandboxDisabled();
|
|
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 (skip if sandbox is disabled)
|
|
if (!sandboxDisabled && !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);
|
|
|
|
// Skip sandbox check for symlink target if sandbox is disabled
|
|
if (!sandboxDisabled && !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 (skip if sandbox is disabled)
|
|
const parentDir = resolve(absolutePath, '..');
|
|
try {
|
|
const realParent = await realpath(parentDir);
|
|
const normalizedParent = normalizePath(realParent);
|
|
|
|
if (!sandboxDisabled && !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);
|
|
}
|