Project Files
src / security.ts
import * as child_process from "child_process";
// Command sanitization - prevent dangerous command injection patterns
export function sanitizeCommand(command: string): { valid: boolean; sanitized?: string; error?: string } {
if (!command || typeof command !== "string") {
return { valid: false, error: "Command must be a non-empty string" };
}
const trimmed = command.trim();
if (trimmed.length === 0) {
return { valid: false, error: "Command cannot be empty or whitespace-only" };
}
// Check for dangerous patterns
const dangerousPatterns = [
/\$\(/, // Command substitution in bash
/`/, // Backticks (command substitution)
/;\s*(rm|del|format)/i, // Dangerous commands after semicolon
/\|\s*(rm|del|format)/i, // Dangerous commands in pipes
/&&\s*(rm|del|format)/i, // Dangerous commands in && chains
];
for (const pattern of dangerousPatterns) {
if (pattern.test(trimmed)) {
return {
valid: false,
error: "Command contains potentially dangerous patterns that are blocked for security"
};
}
}
return { valid: true, sanitized: trimmed };
}
// Validate process execution parameters
export interface ExecutionParams {
timeoutMs?: number;
maxOutputBytes?: number;
cwd?: string;
env?: Record<string, string>;
}
export function validateExecutionParams(params: ExecutionParams): {
valid: boolean;
validated?: ExecutionParams;
error?: string
} {
// Validate timeout
if (params.timeoutMs !== undefined) {
const num = Number(params.timeoutMs);
if (!Number.isInteger(num) || num < 100 || num > 300000) {
return {
valid: false,
error: "timeout_ms must be an integer between 100 and 300000 (ms)"
};
}
}
// Validate max output bytes
if (params.maxOutputBytes !== undefined) {
const num = Number(params.maxOutputBytes);
if (!Number.isInteger(num) || num < 1024 || num > 104857600) {
return {
valid: false,
error: "max_output_bytes must be an integer between 1024 (1KB) and 104857600 (100MB)"
};
}
}
// Validate cwd if provided
if (params.cwd !== undefined && typeof params.cwd === "string") {
try {
const expanded = params.cwd.replace(/^~(?=[/\\]|$)/, os.homedir());
// Just validate it's a string - actual existence will be checked at runtime
} catch {
return {
valid: false,
error: "Invalid cwd path"
};
}
}
// Validate env if provided
if (params.env !== undefined) {
for (const [key, value] of Object.entries(params.env)) {
if (typeof key !== "string" || typeof value !== "string") {
return {
valid: false,
error: "env values must be string-to-string mappings"
};
}
// Limit env var names to prevent injection
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
return {
valid: false,
error: `Invalid environment variable name: ${key}`
};
}
}
}
return {
valid: true,
validated: params
};
}
// Check for potentially dangerous file paths
export function sanitizeFilePath(filePath: string): {
valid: boolean;
sanitized?: string;
error?: string
} {
if (!filePath || typeof filePath !== "string") {
return { valid: false, error: "File path must be a non-empty string" };
}
const expanded = expandPath(filePath);
// Prevent directory traversal attacks
const normalized = normalizePath(expanded);
// Check for dangerous patterns in path
if (normalized.includes("..") && !isSafeRelativePath(normalized)) {
return {
valid: false,
error: "Directory traversal paths are not allowed"
};
}
// Prevent writing to system directories on Windows
if (process.platform === "win32") {
const lower = normalized.toLowerCase();
const dangerousPrefixes = [
"c:\\windows",
"c:\\program files",
"c:\\program files (x86)",
"c:\\programdata",
"c:\\recycler",
"c:\\$recycle.bin"
];
for (const prefix of dangerousPrefixes) {
if (lower.startsWith(prefix)) {
return {
valid: false,
error: "Writing to system directories is not allowed"
};
}
}
}
return { valid: true, sanitized: expanded };
}
function expandPath(p: string): string {
let expanded = p.replace(/^~(?=[/\\]|$)/, os.homedir());
expanded = expanded.replace(/%([^%]+)%/g, (_, key) => process.env[key] ?? `%${key}%`);
return expanded;
}
function normalizePath(p: string): string {
// Simple normalization - in production you might want more robust path handling
return p.replace(/\\/g, "/");
}
function isSafeRelativePath(path: string): boolean {
const parts = path.split("/").filter(p => p && p !== ".");
// Allow relative paths like "./file" or "../parent/file"
if (parts.length === 0) return true;
// Check that no part attempts to escape the current directory structure
for (const part of parts) {
if (part === "..") continue;
if (part.includes("..")) return false;
}
return true;
}
// Rate limiting utility for exec operations
export interface RateLimiter {
execute<T>(fn: () => Promise<T>): Promise<T>;
getStats(): { callsInWindow: number; windowStart: number };
}
export class SimpleRateLimiter implements RateLimiter {
private windowMs: number;
private maxCallsPerWindow: number;
private calls: number[] = [];
constructor(windowMs = 60000, maxCallsPerWindow = 100) {
this.windowMs = windowMs;
this.maxCallsPerWindow = maxCallsPerWindow;
}
execute<T>(fn: () => Promise<T>): Promise<T> {
this.cleanupOldCalls();
if (this.calls.length >= this.maxCallsPerWindow) {
return Promise.reject(new Error("Rate limit exceeded. Too many commands executed recently."));
}
const startTime = Date.now();
this.calls.push(startTime);
return fn().finally(() => {
// Note: We don't remove the call from the array immediately to track actual usage
});
}
private cleanupOldCalls(): void {
const now = Date.now();
const cutoff = now - this.windowMs;
this.calls = this.calls.filter(time => time > cutoff);
}
getStats(): { callsInWindow: number; windowStart: number } {
this.cleanupOldCalls();
return {
callsInWindow: this.calls.length,
windowStart: Date.now() - this.windowMs
};
}
}
// Import os at the top of this file for expandPath function
import * as os from "os";