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:
Martin Oehlert
2026-02-10 11:22:04 +01:00
parent 4825661e02
commit 399d78b938
11 changed files with 1072 additions and 357 deletions
+179 -37
View File
@@ -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;
}
}
}