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
+59 -1
View File
@@ -19,6 +19,7 @@ import { extractFromSource } from './tree-sitter';
import { detectLanguage, isLanguageSupported } from './grammars';
import { logDebug } from '../errors';
import { captureException } from '../sentry';
import { validatePathWithinRoot } from '../utils';
/**
* Progress callback for indexing operations
@@ -127,8 +128,22 @@ export function scanDirectory(
): string[] {
const files: string[] = [];
let count = 0;
const visitedRealPaths = new Set<string>(); // Symlink cycle detection
function walk(dir: string): void {
// Symlink cycle detection: resolve real path and skip if already visited
try {
const realDir = fs.realpathSync(dir);
if (visitedRealPaths.has(realDir)) {
logDebug('Skipping directory to prevent symlink cycle', { dir, realDir });
return;
}
visitedRealPaths.add(realDir);
} catch {
// If realpath fails, skip this directory
return;
}
// Check for .codegraphignore marker file - skip entire directory tree if present
const ignoreMarker = path.join(dir, CODEGRAPH_IGNORE_MARKER);
if (fs.existsSync(ignoreMarker)) {
@@ -149,6 +164,39 @@ export function scanDirectory(
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(rootDir, fullPath);
// Follow symlinked directories, but skip symlinked files to non-project targets
if (entry.isSymbolicLink()) {
try {
const realTarget = fs.realpathSync(fullPath);
const stat = fs.statSync(realTarget);
if (stat.isDirectory()) {
// Check exclusion, then recurse (cycle detection handles the rest)
const dirPattern = relativePath + '/';
let excluded = false;
for (const pattern of config.exclude) {
if (matchesGlob(dirPattern, pattern) || matchesGlob(relativePath, pattern)) {
excluded = true;
break;
}
}
if (!excluded) {
walk(fullPath);
}
} else if (stat.isFile()) {
if (shouldIncludeFile(relativePath, config)) {
files.push(relativePath);
count++;
if (onProgress) {
onProgress(count, relativePath);
}
}
}
} catch {
logDebug('Skipping broken symlink', { path: fullPath });
}
continue;
}
if (entry.isDirectory()) {
// Check if directory should be excluded
const dirPattern = relativePath + '/';
@@ -335,7 +383,17 @@ export class ExtractionOrchestrator {
* Index a single file
*/
async indexFile(relativePath: string): Promise<ExtractionResult> {
const fullPath = path.join(this.rootDir, relativePath);
const fullPath = validatePathWithinRoot(this.rootDir, relativePath);
if (!fullPath) {
return {
nodes: [],
edges: [],
unresolvedReferences: [],
errors: [{ message: `Path traversal blocked: ${relativePath}`, severity: 'error' }],
durationMs: 0,
};
}
// Check file exists and is readable
let content: string;