mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +00:00
security: harden input validation
- analyze-job: sanitize update objects to prevent prototype pollution - validation: add length limits to escapeRegExp for ReDoS protection - api: validate node labels before Cypher query construction - local-backend: add NodeLabelValidator and query templates
This commit is contained in:
parent
bd1d446baa
commit
2d72f0f514
4 changed files with 271 additions and 43 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<T extends Record<string, any>>(
|
||||
obj: T,
|
||||
allowedKeys: Set<string>,
|
||||
): Partial<T> {
|
||||
const result: Partial<T> = {};
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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 }[] = [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue