src / securityEnhanced.ts
src / securityEnhanced.ts
import * as os from "os";
import * as fs from "fs";
import * as path from "path";
// ── Windows System Path Protection ──
const SYSTEM_PATH_PREFIXES = [
"C:\\Windows\\",
"C:\\Program Files\\",
"C:\\Program Files (x86)\\",
"C:\\ProgramData\\",
"C:\\Users\\Public\\",
"C:\\Program Files (x86)\\Common Files\\",
"C:\\Program Files\\Common Files\\",
"C:\\Program Files\\WindowsApps\\",
"C:\\Program Files\\Windows Defender\\",
"C:\\Program Files\\Windows Mail\\",
"C:\\Program Files\\Windows Media Player\\",
"C:\\Program Files\\Windows NT\\",
"C:\\Program Files\\Windows Photo Viewer\\",
"C:\\Program Files\\Windows Portable Devices\\",
"C:\\Program Files\\Windows Security Health\\",
"C:\\Program Files\\Windows Sidebar\\",
"C:\\Program Files\\Windows Defender Advanced Threat Protection\\",
];
/**
* Check if a resolved absolute path points to a Windows system/protected location.
*/
export function isSystemFilePath(resolvedPath: string): boolean {
const normalized = resolvedPath.replace(/\\/g, "/").toLowerCase();
for (const prefix of SYSTEM_PATH_PREFIXES) {
if (normalized.startsWith(prefix.toLowerCase())) {
return true;
}
}
return false;
}
/**
* Validate that a path is safe for write/delete operations.
* Blocks writes to Windows system directories while allowing all other paths.
*/
export function validateWritePath(filePath: string): { valid: boolean; sanitized?: string; error?: string } {
const sanitization = sanitizeFilePath(filePath);
if (!sanitization.valid) {
return sanitization;
}
const resolved = sanitization.sanitized!;
if (isSystemFilePath(resolved)) {
return { valid: false, error: `Write access denied: '${resolved}' is in a protected system directory` };
}
return { valid: true, sanitized: resolved };
}
// ── Original Functions ──
export function expandPath(p: string): string {
if (!p || typeof p !== "string") return p;
let expanded = p.trim();
if (expanded.startsWith("~")) {
expanded = expanded.replace(/^~(?=[/\\]|$)/, os.homedir());
}
expanded = expanded.replace(/%([^%]+)%/g, (_, key) => process.env[key] ?? `%${key}%`);
return expanded;
}
function resolveExistingPath(expandedPath: string): string | null {
try {
return fs.realpathSync(expandedPath);
} catch {
const resolved = path.resolve(expandedPath);
const parent = path.dirname(resolved);
try {
const realParent = fs.realpathSync(parent);
return path.join(realParent, path.basename(resolved));
} catch {
return resolved;
}
}
}
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 trimmed = filePath.trim();
if (trimmed.length === 0) return { valid: false, error: "File path cannot be empty" };
if (trimmed.includes("\u0000")) return { valid: false, error: "File path contains null character" };
if (trimmed.includes("..") && (trimmed.includes("/") || trimmed.includes("\\"))) {
return { valid: false, error: "File path traversal not allowed" };
}
const expanded = expandPath(trimmed);
const resolved = resolveExistingPath(expanded);
if (!resolved) return { valid: false, error: "Failed to resolve file path" };
return { valid: true, sanitized: resolved };
}
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" };
const dangerousKeywords = ["rm ", "del ", "format", "mkfs", "shutdown", "reboot", "rmdir", "rd "];
const separators = [";", "|", "&&", ">"];
for (const sep of separators) {
const partsOriginal = trimmed.split(sep);
if (partsOriginal.length > 1) {
for (const part of partsOriginal.slice(1)) {
const lowerPart = part.trim().toLowerCase();
for (const keyword of dangerousKeywords) {
if (part.trim().startsWith(keyword) || lowerPart.startsWith(keyword)) {
return { valid: false, error: "Command contains potentially dangerous patterns" };
}
}
}
}
}
const suspiciousPatterns = [/eval\s+/i, /source\s+/i];
for (const pattern of suspiciousPatterns) {
if (pattern.test(trimmed)) return { valid: false, error: "Command contains potentially risky patterns" };
}
return { valid: true, sanitized: trimmed };
}
export interface ExecutionParams {
timeoutMs?: number;
maxOutputBytes?: number;
cwd?: string;
env?: Record<string, string>;
stdin?: string;
args?: string[];
}
export function validateExecutionParams(params: ExecutionParams): { valid: boolean; validated?: ExecutionParams; error?: string } {
const validated = { ...params } as ExecutionParams;
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 between 100 and 300000" };
}
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 between 1024 and 104857600" };
}
if (params.cwd !== undefined && typeof params.cwd === "string") {
try { validated.cwd = expandPath(params.cwd); } catch { return { valid: false, error: "Invalid cwd path" }; }
}
if (params.stdin !== undefined) {
if (typeof params.stdin !== "string") return { valid: false, error: "stdin must be a string" };
if (params.stdin.length > 1_048_576) return { valid: false, error: "stdin exceeds maximum size" };
}
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" };
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) return { valid: false, error: `Invalid env var name: ${key}` };
}
}
if (params.args !== undefined) {
if (!Array.isArray(params.args)) return { valid: false, error: "args must be an array" };
for (const arg of params.args) {
if (typeof arg !== "string") return { valid: false, error: "Each arg must be a string" };
if (arg.includes("\u0000")) return { valid: false, error: "arg contains null character" };
}
}
return { valid: true, validated };
}
import * as os from "os";
import * as fs from "fs";
import * as path from "path";
// ── Windows System Path Protection ──
const SYSTEM_PATH_PREFIXES = [
"C:\\Windows\\",
"C:\\Program Files\\",
"C:\\Program Files (x86)\\",
"C:\\ProgramData\\",
"C:\\Users\\Public\\",
"C:\\Program Files (x86)\\Common Files\\",
"C:\\Program Files\\Common Files\\",
"C:\\Program Files\\WindowsApps\\",
"C:\\Program Files\\Windows Defender\\",
"C:\\Program Files\\Windows Mail\\",
"C:\\Program Files\\Windows Media Player\\",
"C:\\Program Files\\Windows NT\\",
"C:\\Program Files\\Windows Photo Viewer\\",
"C:\\Program Files\\Windows Portable Devices\\",
"C:\\Program Files\\Windows Security Health\\",
"C:\\Program Files\\Windows Sidebar\\",
"C:\\Program Files\\Windows Defender Advanced Threat Protection\\",
];
/**
* Check if a resolved absolute path points to a Windows system/protected location.
*/
export function isSystemFilePath(resolvedPath: string): boolean {
const normalized = resolvedPath.replace(/\\/g, "/").toLowerCase();
for (const prefix of SYSTEM_PATH_PREFIXES) {
if (normalized.startsWith(prefix.toLowerCase())) {
return true;
}
}
return false;
}
/**
* Validate that a path is safe for write/delete operations.
* Blocks writes to Windows system directories while allowing all other paths.
*/
export function validateWritePath(filePath: string): { valid: boolean; sanitized?: string; error?: string } {
const sanitization = sanitizeFilePath(filePath);
if (!sanitization.valid) {
return sanitization;
}
const resolved = sanitization.sanitized!;
if (isSystemFilePath(resolved)) {
return { valid: false, error: `Write access denied: '${resolved}' is in a protected system directory` };
}
return { valid: true, sanitized: resolved };
}
// ── Original Functions ──
export function expandPath(p: string): string {
if (!p || typeof p !== "string") return p;
let expanded = p.trim();
if (expanded.startsWith("~")) {
expanded = expanded.replace(/^~(?=[/\\]|$)/, os.homedir());
}
expanded = expanded.replace(/%([^%]+)%/g, (_, key) => process.env[key] ?? `%${key}%`);
return expanded;
}
function resolveExistingPath(expandedPath: string): string | null {
try {
return fs.realpathSync(expandedPath);
} catch {
const resolved = path.resolve(expandedPath);
const parent = path.dirname(resolved);
try {
const realParent = fs.realpathSync(parent);
return path.join(realParent, path.basename(resolved));
} catch {
return resolved;
}
}
}
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 trimmed = filePath.trim();
if (trimmed.length === 0) return { valid: false, error: "File path cannot be empty" };
if (trimmed.includes("\u0000")) return { valid: false, error: "File path contains null character" };
if (trimmed.includes("..") && (trimmed.includes("/") || trimmed.includes("\\"))) {
return { valid: false, error: "File path traversal not allowed" };
}
const expanded = expandPath(trimmed);
const resolved = resolveExistingPath(expanded);
if (!resolved) return { valid: false, error: "Failed to resolve file path" };
return { valid: true, sanitized: resolved };
}
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" };
const dangerousKeywords = ["rm ", "del ", "format", "mkfs", "shutdown", "reboot", "rmdir", "rd "];
const separators = [";", "|", "&&", ">"];
for (const sep of separators) {
const partsOriginal = trimmed.split(sep);
if (partsOriginal.length > 1) {
for (const part of partsOriginal.slice(1)) {
const lowerPart = part.trim().toLowerCase();
for (const keyword of dangerousKeywords) {
if (part.trim().startsWith(keyword) || lowerPart.startsWith(keyword)) {
return { valid: false, error: "Command contains potentially dangerous patterns" };
}
}
}
}
}
const suspiciousPatterns = [/eval\s+/i, /source\s+/i];
for (const pattern of suspiciousPatterns) {
if (pattern.test(trimmed)) return { valid: false, error: "Command contains potentially risky patterns" };
}
return { valid: true, sanitized: trimmed };
}
export interface ExecutionParams {
timeoutMs?: number;
maxOutputBytes?: number;
cwd?: string;
env?: Record<string, string>;
stdin?: string;
args?: string[];
}
export function validateExecutionParams(params: ExecutionParams): { valid: boolean; validated?: ExecutionParams; error?: string } {
const validated = { ...params } as ExecutionParams;
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 between 100 and 300000" };
}
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 between 1024 and 104857600" };
}
if (params.cwd !== undefined && typeof params.cwd === "string") {
try { validated.cwd = expandPath(params.cwd); } catch { return { valid: false, error: "Invalid cwd path" }; }
}
if (params.stdin !== undefined) {
if (typeof params.stdin !== "string") return { valid: false, error: "stdin must be a string" };
if (params.stdin.length > 1_048_576) return { valid: false, error: "stdin exceeds maximum size" };
}
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" };
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) return { valid: false, error: `Invalid env var name: ${key}` };
}
}
if (params.args !== undefined) {
if (!Array.isArray(params.args)) return { valid: false, error: "args must be an array" };
for (const arg of params.args) {
if (typeof arg !== "string") return { valid: false, error: "Each arg must be a string" };
if (arg.includes("\u0000")) return { valid: false, error: "arg contains null character" };
}
}
return { valid: true, validated };
}