security: path validation, ReDoS prevention, picomatch, PID-based file lock
- Add validateProjectPath() to reject sensitive system directories - Add isPathWithinRoot/isPathWithinRootReal for symlink-aware path checks - Replace hand-rolled glob-to-regex with picomatch to prevent ReDoS - Add isSafeRegex() to reject custom patterns with nested quantifiers - Replace FileLock with PID-tracking version that detects stale locks - Add symlink detection in removeDirectory/listDirectoryContents - Add subdirectory name validation in ensureSubdirectory - Add atomicWriteFileSync and corrupted file backup in config-writer - Add MCP input validation (validateString) for all tool handlers - Fix CLAUDE.md section replacement to handle ### subsections correctly
This commit is contained in:
+179
-37
@@ -29,13 +29,23 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// ============================================================
|
||||
// SECURITY UTILITIES
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Sensitive system directories that should never be used as project roots.
|
||||
* Checked on all platforms; non-applicable paths are harmlessly skipped.
|
||||
*/
|
||||
const SENSITIVE_PATHS = new Set([
|
||||
'/', '/etc', '/usr', '/bin', '/sbin', '/var', '/tmp', '/dev', '/proc', '/sys',
|
||||
'/root', '/boot', '/lib', '/lib64', '/opt',
|
||||
'C:\\', 'C:\\Windows', 'C:\\Windows\\System32',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate that a resolved file path stays within the project root.
|
||||
* Prevents path traversal attacks (e.g. node.filePath = "../../etc/passwd").
|
||||
@@ -54,6 +64,88 @@ export function validatePathWithinRoot(projectRoot: string, filePath: string): s
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path is a safe project root directory.
|
||||
*
|
||||
* Rejects sensitive system directories and ensures the path is
|
||||
* a real, existing directory. Used at MCP and API entry points
|
||||
* to prevent arbitrary directory access.
|
||||
*
|
||||
* @param dirPath - The path to validate
|
||||
* @returns An error message if invalid, or null if valid
|
||||
*/
|
||||
export function validateProjectPath(dirPath: string): string | null {
|
||||
const resolved = path.resolve(dirPath);
|
||||
|
||||
// Block sensitive system directories
|
||||
if (SENSITIVE_PATHS.has(resolved) || SENSITIVE_PATHS.has(resolved.toLowerCase())) {
|
||||
return `Refusing to operate on sensitive system directory: ${resolved}`;
|
||||
}
|
||||
|
||||
// Also block common sensitive home subdirectories
|
||||
const homeDir = require('os').homedir();
|
||||
const sensitiveHomeDirs = ['.ssh', '.gnupg', '.aws', '.config'];
|
||||
for (const dir of sensitiveHomeDirs) {
|
||||
const sensitivePath = path.join(homeDir, dir);
|
||||
if (resolved === sensitivePath || resolved.startsWith(sensitivePath + path.sep)) {
|
||||
return `Refusing to operate on sensitive directory: ${resolved}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify it's a real directory
|
||||
try {
|
||||
const stats = fs.statSync(resolved);
|
||||
if (!stats.isDirectory()) {
|
||||
return `Path is not a directory: ${resolved}`;
|
||||
}
|
||||
} catch {
|
||||
return `Path does not exist or is not accessible: ${resolved}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file path resolves to a location within the given root directory.
|
||||
*
|
||||
* Prevents path traversal attacks by ensuring the resolved absolute path
|
||||
* starts with the resolved root path. Handles '..' sequences, symlink-like
|
||||
* relative paths, and platform-specific separators.
|
||||
*
|
||||
* @param filePath - The path to check (can be relative or absolute)
|
||||
* @param rootDir - The root directory that filePath must stay within
|
||||
* @returns true if filePath resolves to a location within rootDir
|
||||
*/
|
||||
export function isPathWithinRoot(filePath: string, rootDir: string): boolean {
|
||||
const resolvedPath = path.resolve(rootDir, filePath);
|
||||
const resolvedRoot = path.resolve(rootDir);
|
||||
return resolvedPath.startsWith(resolvedRoot + path.sep) || resolvedPath === resolvedRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like isPathWithinRoot but also resolves symlinks via fs.realpathSync.
|
||||
*
|
||||
* This catches symlink escapes where the logical path appears to be within
|
||||
* root but the real path on disk points elsewhere. Falls back to logical
|
||||
* path checking if realpath resolution fails (e.g. broken symlink).
|
||||
*/
|
||||
export function isPathWithinRootReal(filePath: string, rootDir: string): boolean {
|
||||
// First do the cheap logical check
|
||||
if (!isPathWithinRoot(filePath, rootDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Then verify with realpath to catch symlink escapes
|
||||
try {
|
||||
const realPath = fs.realpathSync(path.resolve(rootDir, filePath));
|
||||
const realRoot = fs.realpathSync(rootDir);
|
||||
return realPath.startsWith(realRoot + path.sep) || realPath === realRoot;
|
||||
} catch {
|
||||
// If realpath fails (broken symlink, permissions), fall back to logical check
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON with a fallback value.
|
||||
* Prevents crashes from corrupted database metadata.
|
||||
@@ -75,63 +167,113 @@ export function clamp(value: number, min: number, max: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-process file lock using lock files.
|
||||
* Prevents concurrent database writes from CLI, MCP server, and git hooks.
|
||||
* Cross-process file lock using a lock file with PID tracking.
|
||||
*
|
||||
* Prevents multiple processes (e.g., git hooks, CLI, MCP server) from
|
||||
* writing to the same database simultaneously.
|
||||
*/
|
||||
export class FileLock {
|
||||
private lockPath: string;
|
||||
private acquired = false;
|
||||
private held = false;
|
||||
|
||||
constructor(resourcePath: string) {
|
||||
this.lockPath = resourcePath + '.lock';
|
||||
constructor(lockPath: string) {
|
||||
this.lockPath = lockPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the file lock. Waits up to timeoutMs for the lock.
|
||||
* Cleans up stale locks older than staleLockMs.
|
||||
* Acquire the lock. Throws if the lock is held by another live process.
|
||||
*/
|
||||
async acquire(timeoutMs: number = 10000, staleLockMs: number = 30000): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
acquire(): void {
|
||||
// Check for existing lock
|
||||
if (fs.existsSync(this.lockPath)) {
|
||||
try {
|
||||
// Try to create lock file exclusively
|
||||
fs.writeFileSync(this.lockPath, String(process.pid), { flag: 'wx' });
|
||||
this.acquired = true;
|
||||
return true;
|
||||
} catch {
|
||||
// Lock file exists - check if stale
|
||||
try {
|
||||
const stat = fs.statSync(this.lockPath);
|
||||
if (Date.now() - stat.mtimeMs > staleLockMs) {
|
||||
// Stale lock - remove and retry
|
||||
fs.unlinkSync(this.lockPath);
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Lock file disappeared between check and stat - retry
|
||||
continue;
|
||||
const content = fs.readFileSync(this.lockPath, 'utf-8').trim();
|
||||
const pid = parseInt(content, 10);
|
||||
|
||||
if (!isNaN(pid) && this.isProcessAlive(pid)) {
|
||||
throw new Error(
|
||||
`CodeGraph database is locked by another process (PID ${pid}). ` +
|
||||
`If this is stale, delete ${this.lockPath}`
|
||||
);
|
||||
}
|
||||
|
||||
// Wait and retry
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
// Stale lock - remove it
|
||||
fs.unlinkSync(this.lockPath);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('locked by another')) {
|
||||
throw err;
|
||||
}
|
||||
// Other errors reading lock file - try to remove it
|
||||
try { fs.unlinkSync(this.lockPath); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
// Write our PID to the lock file using exclusive create flag
|
||||
try {
|
||||
fs.writeFileSync(this.lockPath, String(process.pid), { flag: 'wx' });
|
||||
this.held = true;
|
||||
} catch (err: any) {
|
||||
if (err.code === 'EEXIST') {
|
||||
// Race condition: another process grabbed the lock between our check and write
|
||||
throw new Error(
|
||||
'CodeGraph database is locked by another process. ' +
|
||||
`If this is stale, delete ${this.lockPath}`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the file lock
|
||||
* Release the lock
|
||||
*/
|
||||
release(): void {
|
||||
if (this.acquired) {
|
||||
try {
|
||||
if (!this.held) return;
|
||||
try {
|
||||
// Only remove if we still own it (check PID)
|
||||
const content = fs.readFileSync(this.lockPath, 'utf-8').trim();
|
||||
if (parseInt(content, 10) === process.pid) {
|
||||
fs.unlinkSync(this.lockPath);
|
||||
} catch {
|
||||
// Lock file already removed - that's fine
|
||||
}
|
||||
this.acquired = false;
|
||||
} catch {
|
||||
// Lock file already gone - that's fine
|
||||
}
|
||||
this.held = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function while holding the lock
|
||||
*/
|
||||
withLock<T>(fn: () => T): T {
|
||||
this.acquire();
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an async function while holding the lock
|
||||
*/
|
||||
async withLockAsync<T>(fn: () => Promise<T>): Promise<T> {
|
||||
this.acquire();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a process is still running
|
||||
*/
|
||||
private isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user