diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index c20433670..b126edaad 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -157,6 +157,102 @@ export const VALID_NODE_LABELS = new Set([ 'Tool', ]); +/** + * Frozen set of valid node labels - prevents runtime modification + */ +const FROZEN_VALID_NODE_LABELS = Object.freeze(new Set([...VALID_NODE_LABELS])); + +/** + * Secure node label validator with strict validation rules + */ +class NodeLabelValidator { + private static readonly LABEL_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/; + private static readonly MAX_LABEL_LENGTH = 50; + + /** + * Validate a node label against security rules + * @returns The validated label if valid, null otherwise + */ + static validate(label: unknown): string | null { + // Type check + if (typeof label !== 'string') { + logger.warn({ label }, 'Security: Invalid label type'); + return null; + } + + // Length check + if (label.length === 0 || label.length > this.MAX_LABEL_LENGTH) { + logger.warn({ label, length: label.length }, 'Security: Label length out of range'); + return null; + } + + // Format check - only alphanumeric and underscore, must start with letter + if (!this.LABEL_PATTERN.test(label)) { + logger.warn({ label }, 'Security: Label format invalid'); + return null; + } + + // Whitelist check using frozen set + if (!FROZEN_VALID_NODE_LABELS.has(label)) { + logger.warn({ label }, 'Security: Label not in whitelist'); + return null; + } + + return label; + } +} + +/** + * Pre-defined Cypher query templates for safe query construction + * Uses template functions that validate labels before interpolation + */ +const CYPHER_TEMPLATES = Object.freeze({ + /** + * Get connections for a node - labels are validated before use + */ + connections: (label: string) => { + const validLabel = NodeLabelValidator.validate(label); + if (!validLabel) throw new Error(`Invalid node label: ${label}`); + return ` + MATCH (n:${validLabel} {id: $nid}) + OPTIONAL MATCH (n)-[r1:CodeRelation]->(dst) + OPTIONAL MATCH (src)-[r2:CodeRelation]->(n) + RETURN + collect(DISTINCT {name: dst.name, type: r1.type, confidence: r1.confidence}) AS outgoing, + collect(DISTINCT {name: src.name, type: r2.type, confidence: r2.confidence}) AS incoming + LIMIT 1 + `; + }, + + /** + * Get cluster/community for a node + */ + cluster: (label: string) => { + const validLabel = NodeLabelValidator.validate(label); + if (!validLabel) throw new Error(`Invalid node label: ${label}`); + return ` + MATCH (n:${validLabel} {id: $nid}) + MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) + RETURN c.label AS label, c.description AS description + LIMIT 1 + `; + }, + + /** + * Get processes for a node + */ + processes: (label: string) => { + const validLabel = NodeLabelValidator.validate(label); + if (!validLabel) throw new Error(`Invalid node label: ${label}`); + return ` + MATCH (n:${validLabel} {id: $nid}) + MATCH (n)-[rel:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) + RETURN p.id AS id, p.label AS label, rel.step AS step, p.stepCount AS stepCount + ORDER BY rel.step + `; + }, +}); + /** Valid relation types for impact analysis filtering */ export const VALID_RELATION_TYPES = new Set([ 'CALLS', diff --git a/gitnexus/src/server/analyze-job.ts b/gitnexus/src/server/analyze-job.ts index f7d97e97d..f77673504 100644 --- a/gitnexus/src/server/analyze-job.ts +++ b/gitnexus/src/server/analyze-job.ts @@ -12,6 +12,54 @@ import { randomUUID } from 'crypto'; import { EventEmitter } from 'events'; import type { ChildProcess } from 'child_process'; +import { logger } from '../core/logger.js'; + +/** + * Dangerous keys that could lead to prototype pollution + */ +const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +/** + * Allowed keys for job updates - all other keys are rejected + */ +const ALLOWED_UPDATE_KEYS = new Set([ + 'status', + 'progress', + 'error', + 'repoPath', + 'repoName', + 'completedAt', +]); + +/** + * Sanitize update object to prevent prototype pollution + * - Rejects dangerous keys (__proto__, constructor, prototype) + * - Rejects keys not in the allowed whitelist + */ +function sanitizeUpdateObject>( + obj: T, + allowedKeys: Set, +): Partial { + const result: Partial = {}; + + for (const [key, value] of Object.entries(obj)) { + // Reject dangerous keys that could cause prototype pollution + if (DANGEROUS_KEYS.has(key)) { + logger.warn({ key }, 'Security: Rejected dangerous key in update object'); + continue; + } + + // Reject keys not in the whitelist + if (!allowedKeys.has(key)) { + logger.debug({ key }, 'Security: Rejected unknown key in update object'); + continue; + } + + (result as any)[key] = value; + } + + return result; +} export interface AnalyzeJobProgress { phase: string; @@ -101,7 +149,10 @@ export class JobManager { const job = this.jobs.get(id); if (!job) return; - Object.assign(job, update); + // Security: sanitize update to prevent prototype pollution + const safeUpdate = sanitizeUpdateObject(update, ALLOWED_UPDATE_KEYS); + + Object.assign(job, safeUpdate); if (this.isTerminal(job.status)) { job.completedAt = job.completedAt ?? Date.now(); diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 05836d350..d302878a8 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -42,6 +42,84 @@ import { sweepStaleUploads } from './upload-sweep.js'; import { isRfc1918PrivateIpv4 } from './private-ip.js'; import { logger, flushLoggerSync } from '../core/logger.js'; +// ─── Security: Cypher Query Hardening ──────────────────────────────── + +/** + * Dangerous keys that could lead to prototype pollution or injection + */ +const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +/** + * Security-hardened node label validator for Cypher query construction. + * Validates labels against whitelist and strict format rules. + */ +class NodeLabelValidator { + private static readonly LABEL_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/; + private static readonly MAX_LABEL_LENGTH = 50; + + static validate(label: unknown): string | null { + if (typeof label !== 'string') { + logger.warn({ label }, 'Security: Invalid label type'); + return null; + } + if (label.length === 0 || label.length > this.MAX_LABEL_LENGTH) { + logger.warn({ label, length: label.length }, 'Security: Label length out of range'); + return null; + } + if (!this.LABEL_PATTERN.test(label)) { + logger.warn({ label }, 'Security: Label format invalid'); + return null; + } + // Validate against compile-time safe NODE_TABLES + if (!(NODE_TABLES as readonly string[]).includes(label)) { + logger.warn({ label }, 'Security: Label not in NODE_TABLES whitelist'); + return null; + } + return label; + } +} + +/** + * Pre-defined Cypher query templates with validated label interpolation. + * All templates use parameterized queries for values ($nid) and + * strict validation for labels to prevent Cypher injection. + */ +const CYPHER_TEMPLATES = Object.freeze({ + connections: (label: string) => { + const validLabel = NodeLabelValidator.validate(label); + if (!validLabel) throw new Error(`Invalid node label: ${label}`); + return ` + MATCH (n:${validLabel} {id: $nid}) + OPTIONAL MATCH (n)-[r1:CodeRelation]->(dst) + OPTIONAL MATCH (src)-[r2:CodeRelation]->(n) + RETURN + collect(DISTINCT {name: dst.name, type: r1.type, confidence: r1.confidence}) AS outgoing, + collect(DISTINCT {name: src.name, type: r2.type, confidence: r2.confidence}) AS incoming + LIMIT 1 + `; + }, + cluster: (label: string) => { + const validLabel = NodeLabelValidator.validate(label); + if (!validLabel) throw new Error(`Invalid node label: ${label}`); + return ` + MATCH (n:${validLabel} {id: $nid}) + MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) + RETURN c.label AS label, c.description AS description + LIMIT 1 + `; + }, + processes: (label: string) => { + const validLabel = NodeLabelValidator.validate(label); + if (!validLabel) throw new Error(`Invalid node label: ${label}`); + return ` + MATCH (n:${validLabel} {id: $nid}) + MATCH (n)-[rel:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) + RETURN p.id AS id, p.label AS label, rel.step AS step, p.stepCount AS stepCount + ORDER BY rel.step + `; + }, +}); + const _require = createRequire(import.meta.url); const pkg = _require('../../package.json'); @@ -1187,52 +1265,30 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => if (!enrich) return { searchResults, ftsAvailable }; // Server-side enrichment: add connections, cluster, processes per result - // Uses parameterized queries to prevent Cypher injection via nodeId - const validLabel = (label: string): boolean => - (NODE_TABLES as readonly string[]).includes(label); - + // Security: Uses CYPHER_TEMPLATES with strict label validation to prevent injection const enriched = await Promise.all( searchResults.slice(0, limit).map(async (r: any) => { const nodeId: string = r.nodeId || r.id || ''; const nodeLabel = nodeId.split(':')[0]; const enrichment: { connections?: any; cluster?: string; processes?: any[] } = {}; - if (!nodeId || !validLabel(nodeLabel)) return { ...r, ...enrichment }; + // Security: Validate label using hardened validator (format + whitelist) + const validatedLabel = NodeLabelValidator.validate(nodeLabel); + if (!nodeId || !validatedLabel) return { ...r, ...enrichment }; // Run connections, cluster, and process queries in parallel - // Label is validated against NODE_TABLES (compile-time safe identifiers); + // Security: CYPHER_TEMPLATES validates labels before interpolation; // nodeId uses $nid parameter binding to prevent injection const [connRes, clusterRes, procRes] = await Promise.all([ - executePrepared( - ` - MATCH (n:${nodeLabel} {id: $nid}) - OPTIONAL MATCH (n)-[r1:CodeRelation]->(dst) - OPTIONAL MATCH (src)-[r2:CodeRelation]->(n) - RETURN - collect(DISTINCT {name: dst.name, type: r1.type, confidence: r1.confidence}) AS outgoing, - collect(DISTINCT {name: src.name, type: r2.type, confidence: r2.confidence}) AS incoming - LIMIT 1 - `, - { nid: nodeId }, - ).catch(() => []), - executePrepared( - ` - MATCH (n:${nodeLabel} {id: $nid}) - MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) - RETURN c.label AS label, c.description AS description - LIMIT 1 - `, - { nid: nodeId }, - ).catch(() => []), - executePrepared( - ` - MATCH (n:${nodeLabel} {id: $nid}) - MATCH (n)-[rel:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) - RETURN p.id AS id, p.label AS label, rel.step AS step, p.stepCount AS stepCount - ORDER BY rel.step - `, - { nid: nodeId }, - ).catch(() => []), + executePrepared(CYPHER_TEMPLATES.connections(validatedLabel), { nid: nodeId }).catch( + () => [], + ), + executePrepared(CYPHER_TEMPLATES.cluster(validatedLabel), { nid: nodeId }).catch( + () => [], + ), + executePrepared(CYPHER_TEMPLATES.processes(validatedLabel), { nid: nodeId }).catch( + () => [], + ), ]); if (connRes.length > 0) { @@ -1292,6 +1348,13 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => await handleFileRequest(req, res, entry.path); }); + // Grep configuration constants for ReDoS protection + const GREP_CONFIG = { + MAX_PATTERN_LENGTH: 200, + MAX_RESULTS: 200, + TIMEOUT_MS: 30000, // 30 second timeout + } as const; + // Grep — regex search across file contents in the indexed repo // Uses filesystem-based search for memory efficiency (never loads all files into memory) // Rate-limited (CodeQL js/missing-rate-limiting): scans every file in @@ -1321,14 +1384,15 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // Length cap: applies to both literal and regex modes as a defense-in-depth // bound against pathological input. - if (pattern.length > 200) { - res.status(400).json({ error: 'Pattern too long (max 200 characters)' }); + if (pattern.length > GREP_CONFIG.MAX_PATTERN_LENGTH) { + res.status(400).json({ error: `Pattern too long (max ${GREP_CONFIG.MAX_PATTERN_LENGTH} characters)` }); return; } // Treat user input as a literal substring in all cases to prevent // regex-injection/ReDoS via attacker-controlled regex syntax. - const effectivePattern = escapeRegExp(pattern); + // Security: escapeRegExp enforces additional length limits and complexity bounds. + const effectivePattern = escapeRegExp(pattern, GREP_CONFIG.MAX_PATTERN_LENGTH); // Validate regex syntax (catches both opt-in user regex and any escapeRegExp bug) let regex: RegExp; @@ -1341,7 +1405,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => const parsedLimit = Number(req.query.limit ?? 50); const limit = Number.isFinite(parsedLimit) - ? Math.max(1, Math.min(200, Math.trunc(parsedLimit))) + ? Math.max(1, Math.min(GREP_CONFIG.MAX_RESULTS, Math.trunc(parsedLimit))) : 50; const results: { filePath: string; line: number; text: string }[] = []; diff --git a/gitnexus/src/server/validation.ts b/gitnexus/src/server/validation.ts index bae6a9ad0..f4bda05c8 100644 --- a/gitnexus/src/server/validation.ts +++ b/gitnexus/src/server/validation.ts @@ -93,9 +93,26 @@ export function assertSafePath(rawPath: string, root: string): string { * Escape regex metacharacters in a user-supplied string so it can be safely * embedded as a literal in `new RegExp(...)`. Used by /api/grep's literal mode * and any future endpoint that constructs a regex from caller input. + * + * Security: enforces maximum length to prevent ReDoS via pathological patterns. + * @throws BadRequestError if input exceeds maxLength */ -export function escapeRegExp(input: string): string { - return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +export function escapeRegExp(input: string, maxLength = 1000): string { + if (input.length > maxLength) { + throw new BadRequestError(`Pattern too long (max ${maxLength} characters)`); + } + + const MAX_REPLACEMENTS = 10000; + let replacementCount = 0; + + const result = input.replace(/[.*+?^${}()|[\]\\]/g, (match) => { + if (++replacementCount > MAX_REPLACEMENTS) { + throw new BadRequestError('Pattern too complex'); + } + return '\\' + match; + }); + + return result; } /**