Security hardening: path validation, input clamping, safe JSON, file locking

Implements security improvements inspired by PR #16 (credit: MO2k4):

- Add validatePathWithinRoot() to prevent path traversal attacks in
  extraction and context building
- Clamp MCP tool inputs (limit, depth, maxDepth) to sane ranges
- Use atomic writes (temp file + rename) for config saves
- Add symlink cycle detection in directory scanning to prevent infinite loops
- Replace all JSON.parse calls in db/queries.ts with safeJsonParse fallbacks
  to handle corrupted database metadata gracefully
- Add cross-process FileLock for DB write operations (indexAll, indexFiles,
  sync) to prevent concurrent writes from CLI, MCP server, and git hooks
- Remove unused path import from context/index.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-02-09 23:18:40 -06:00
co-authored by Claude Opus 4.6
parent 38fac1ff28
commit 932c567d18
7 changed files with 257 additions and 55 deletions
+110 -2
View File
@@ -1,13 +1,14 @@
/**
* CodeGraph Utilities
*
* Common utility functions for memory management, concurrency, and batching.
* Common utility functions for memory management, concurrency, batching,
* and security validation.
*
* @module utils
*
* @example
* ```typescript
* import { Mutex, processInBatches, MemoryMonitor } from 'codegraph';
* import { Mutex, processInBatches, MemoryMonitor, validatePathWithinRoot } from 'codegraph';
*
* // Use mutex for concurrent safety
* const mutex = new Mutex();
@@ -28,6 +29,113 @@
* ```
*/
import * as path from 'path';
import * as fs from 'fs';
// ============================================================
// SECURITY UTILITIES
// ============================================================
/**
* Validate that a resolved file path stays within the project root.
* Prevents path traversal attacks (e.g. node.filePath = "../../etc/passwd").
*
* @param projectRoot - The project root directory
* @param filePath - The relative file path to validate
* @returns The resolved absolute path, or null if it escapes the root
*/
export function validatePathWithinRoot(projectRoot: string, filePath: string): string | null {
const resolved = path.resolve(projectRoot, filePath);
const normalizedRoot = path.resolve(projectRoot);
if (!resolved.startsWith(normalizedRoot + path.sep) && resolved !== normalizedRoot) {
return null;
}
return resolved;
}
/**
* Safely parse JSON with a fallback value.
* Prevents crashes from corrupted database metadata.
*/
export function safeJsonParse<T>(value: string, fallback: T): T {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
/**
* Clamp a numeric value to a range.
* Used to enforce sane limits on MCP tool inputs.
*/
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/**
* Cross-process file lock using lock files.
* Prevents concurrent database writes from CLI, MCP server, and git hooks.
*/
export class FileLock {
private lockPath: string;
private acquired = false;
constructor(resourcePath: string) {
this.lockPath = resourcePath + '.lock';
}
/**
* Acquire the file lock. Waits up to timeoutMs for the lock.
* Cleans up stale locks older than staleLockMs.
*/
async acquire(timeoutMs: number = 10000, staleLockMs: number = 30000): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
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;
}
// Wait and retry
await new Promise(resolve => setTimeout(resolve, 100));
}
}
return false;
}
/**
* Release the file lock
*/
release(): void {
if (this.acquired) {
try {
fs.unlinkSync(this.lockPath);
} catch {
// Lock file already removed - that's fine
}
this.acquired = false;
}
}
}
/**
* Process items in batches to manage memory
*