Skip to main content
Glama
security.utils.ts15.2 kB
import * as fs from 'fs'; import * as path from 'path'; /** * Security configuration for repository analysis */ export interface SecurityConfig { maxFiles: number; maxDirectoryDepth: number; maxFileSize: number; // in bytes allowedExtensions: string[]; blockedPaths: string[]; } /** * Default security configuration */ export const DEFAULT_SECURITY_CONFIG: SecurityConfig = { maxFiles: 10000, // Prevent memory exhaustion maxDirectoryDepth: 20, // Prevent stack overflow maxFileSize: 10 * 1024 * 1024, // 10MB per file allowedExtensions: ['.js', '.ts', '.json', '.md', '.txt', '.yml', '.yaml', '.toml'], blockedPaths: ['node_modules', '.git', 'dist', 'build', 'coverage', '.next'], }; /** * Security constants for path validation */ const MAX_PATH_LENGTH = 4096; // Unix PATH_MAX limit for security const MAX_COMPONENT_LENGTH = 255; // Maximum filename component length const MAX_DEPTH = 100; // Maximum directory nesting depth /** * Validates and normalizes a file path to prevent path traversal attacks. * Handles Windows drive letters, UNC paths, and absolute path inputs robustly. * Includes security validation for path length limits to prevent buffer overflow * and denial of service attacks. * * @param targetPath - The path to validate (can be relative or absolute) * @param rootPath - The root directory that should contain the target * @returns Normalized safe path within the root directory * @throws Error if path is invalid, absolute (when not allowed), or traverses outside root * * @example * ```typescript * // Valid relative paths * validatePath('src/file.ts', '/project/root') // → '/project/root/src/file.ts' * validatePath('./src/file.ts', '/project/root') // → '/project/root/src/file.ts' * * // Invalid traversal attempts * validatePath('../../../etc/passwd', '/project/root') // → throws Error * validatePath('/etc/passwd', '/project/root') // → throws Error (absolute path) * * // Windows paths * validatePath('src\\file.ts', 'C:\\project') // → 'C:\\project\\src\\file.ts' * ``` */ export function validatePath(targetPath: string, rootPath: string): string { // Input validation if (!targetPath || typeof targetPath !== 'string') { throw new Error('Target path must be a non-empty string'); } if (!rootPath || typeof rootPath !== 'string') { throw new Error('Root path must be a non-empty string'); } // Security validation: Check input path length to prevent DoS if (targetPath.length > MAX_PATH_LENGTH) { throw new Error( `Path too long: ${targetPath.length} characters (maximum allowed: ${MAX_PATH_LENGTH})`, ); } // Security validation: Check individual path components const pathComponents = targetPath.split(/[\/\\]+/); let depth = 0; for (const component of pathComponents) { if (component === '' || component === '.') { continue; // Skip empty components and current directory references } if (component === '..') { depth--; continue; // Parent directory references will be caught by traversal detection } depth++; // Check component length to prevent buffer overflow if (component.length > MAX_COMPONENT_LENGTH) { throw new Error( `Path component too long: "${component.substring(0, 50)}..." ` + `(${component.length} characters, maximum allowed: ${MAX_COMPONENT_LENGTH})`, ); } } // Security validation: Check directory depth to prevent stack overflow if (depth > MAX_DEPTH) { throw new Error(`Path too deep: ${depth} levels (maximum allowed: ${MAX_DEPTH})`); } // Normalize root path to absolute form const normalizedRoot = path.resolve(rootPath); // Handle absolute paths - allow if they resolve within the root directory let normalizedTarget: string; if (path.isAbsolute(targetPath)) { // For absolute paths, use them directly but still validate containment normalizedTarget = path.resolve(targetPath); } else { // For relative paths, resolve them relative to root normalizedTarget = path.resolve(normalizedRoot, targetPath); } // Check for Windows-style paths regardless of platform for security // Handle Windows drive letters (C:, D:, etc.) if (/^[A-Za-z]:/.test(targetPath)) { throw new Error(`Absolute drive path not allowed: ${targetPath}`); } // Handle UNC paths (\\server\share) if (targetPath.startsWith('\\\\')) { throw new Error(`UNC path not allowed: ${targetPath}`); } // Note: normalizedTarget is already set above based on path type // Use path.relative to robustly check containment const relativePath = path.relative(normalizedRoot, normalizedTarget); // Check for path traversal attempts if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { throw new Error( `Path traversal attempt detected: ${targetPath} (resolves outside root: ${relativePath})`, ); } // Additional Windows-specific validation if (process.platform === 'win32') { // Ensure no drive letter changes occurred during resolution const rootDrive = path.parse(normalizedRoot).root; const targetDrive = path.parse(normalizedTarget).root; if (rootDrive.toLowerCase() !== targetDrive.toLowerCase()) { throw new Error( `Cross-drive path traversal detected: ${targetPath} (${rootDrive} → ${targetDrive})`, ); } } // Final security validation: Check resolved path length if (normalizedTarget.length > MAX_PATH_LENGTH) { throw new Error( `Resolved path too long: ${normalizedTarget.length} characters (maximum allowed: ${MAX_PATH_LENGTH})`, ); } return normalizedTarget; } /** * Safely parses JSON with comprehensive error handling * @param jsonString - The JSON string to parse * @param fallback - Default value if parsing fails * @param maxLength - Maximum allowed string length * @returns Parsed object or fallback */ export function safeJsonParse<T>( jsonString: unknown, fallback: T, maxLength: number = 1024 * 1024, // 1MB default ): T { try { // Validate input exists and is a string if (jsonString === null || jsonString === undefined) { return fallback; } if (typeof jsonString !== 'string') { return fallback; } // Check string length to prevent DoS if (jsonString.length > maxLength) { throw new Error(`JSON string too large: ${jsonString.length} bytes (max: ${maxLength})`); } // Trim whitespace and check for empty string const trimmed = jsonString.trim(); if (trimmed === '') { return fallback; } // Parse JSON with additional validation const parsed = JSON.parse(trimmed); // Validate parsed result is not null if (parsed === null) { return fallback; } return parsed as T; } catch (error) { // Log parsing error for debugging (but don't throw) if (process.env.NODE_ENV !== 'production') { console.warn(`JSON parse failed: ${error instanceof Error ? error.message : String(error)}`); } return fallback; } } /** * Resource manager for file operations with automatic cleanup */ export class ResourceManager { private openHandles: Set<fs.promises.FileHandle> = new Set(); /** * Safely read a file with automatic handle cleanup * @param filePath - Path to file * @param encoding - File encoding * @param maxSize - Maximum file size in bytes * @returns File content */ async readFileSecurely( filePath: string, encoding: BufferEncoding = 'utf-8', maxSize: number = DEFAULT_SECURITY_CONFIG.maxFileSize, ): Promise<string> { let handle: fs.promises.FileHandle | null = null; try { // Check file size before reading const stats = await fs.promises.stat(filePath); if (stats.size > maxSize) { throw new Error(`File too large: ${filePath} is ${stats.size} bytes (max: ${maxSize})`); } // Open file handle handle = await fs.promises.open(filePath, 'r'); this.openHandles.add(handle); // Read file content const content = await handle.readFile({ encoding }); return content; } finally { // Always clean up handle if (handle) { this.openHandles.delete(handle); await handle.close().catch(() => { // Ignore close errors but log them if (process.env.NODE_ENV !== 'production') { console.warn(`Failed to close file handle for: ${filePath}`); } }); } } } /** * Check if a path should be ignored for security/performance reasons * @param relativePath - Relative path from root * @param config - Security configuration * @returns true if path should be ignored */ shouldIgnorePath( relativePath: string, config: SecurityConfig = DEFAULT_SECURITY_CONFIG, ): boolean { const normalizedPath = relativePath.toLowerCase(); // Check against blocked paths for (const blockedPath of config.blockedPaths) { if (normalizedPath.includes(blockedPath.toLowerCase())) { return true; } } // Check file extension if it's a file const ext = path.extname(normalizedPath); if (ext && !config.allowedExtensions.includes(ext)) { return true; } return false; } /** * Clean up any remaining open handles */ async cleanup(): Promise<void> { const cleanupPromises = Array.from(this.openHandles).map(async (handle) => { try { await handle.close(); } catch (error) { // Log but don't throw during cleanup if (process.env.NODE_ENV !== 'production') { console.warn(`Failed to close handle during cleanup: ${error}`); } } }); await Promise.allSettled(cleanupPromises); this.openHandles.clear(); } } /** * Memory usage monitor for large operations */ export class MemoryMonitor { private initialMemory: number; private maxMemoryIncrease: number; constructor(maxMemoryIncreaseMB: number = 500) { this.initialMemory = process.memoryUsage().heapUsed; this.maxMemoryIncrease = maxMemoryIncreaseMB * 1024 * 1024; // Convert to bytes } /** * Check if memory usage is within acceptable limits * @throws Error if memory usage exceeds limits */ checkMemoryUsage(): void { const currentMemory = process.memoryUsage().heapUsed; const memoryIncrease = currentMemory - this.initialMemory; if (memoryIncrease > this.maxMemoryIncrease) { throw new Error( `Memory usage exceeded limit: ${Math.round(memoryIncrease / 1024 / 1024)}MB increase ` + `(max: ${Math.round(this.maxMemoryIncrease / 1024 / 1024)}MB)`, ); } } /** * Get current memory usage statistics */ getMemoryStats(): { current: number; increase: number; limit: number } { const currentMemory = process.memoryUsage().heapUsed; const memoryIncrease = currentMemory - this.initialMemory; return { current: Math.round(currentMemory / 1024 / 1024), // MB increase: Math.round(memoryIncrease / 1024 / 1024), // MB limit: Math.round(this.maxMemoryIncrease / 1024 / 1024), // MB }; } } /** * Secure process spawning configuration for test environments */ export interface SecureSpawnConfig { readonly allowedCommands: readonly string[]; readonly allowedArgs: readonly string[]; readonly timeout: number; readonly maxMemory: number; readonly env: Record<string, string>; } export const DEFAULT_TEST_SPAWN_CONFIG: SecureSpawnConfig = { allowedCommands: ['npx', 'node', 'tsx'] as const, allowedArgs: ['tsx', '-r', 'ts-node/register'] as const, timeout: 30000, // 30 seconds maxMemory: 512 * 1024 * 1024, // 512MB env: { NODE_ENV: 'test', NODE_OPTIONS: '--max-old-space-size=512', }, } as const; /** * Security error for process spawning violations */ export class ProcessSpawnSecurityError extends Error { constructor( message: string, public readonly violation: string, ) { super(`Process spawn security violation: ${message}`); this.name = 'ProcessSpawnSecurityError'; } } /** * Type-safe helper to check if a string is in a readonly string array * @param value - String to check * @param allowedValues - Readonly array of allowed strings * @returns true if value is in the allowed values */ function isStringInReadonlyArray(value: string, allowedValues: readonly string[]): boolean { return (allowedValues as string[]).includes(value); } /** * Securely validate and spawn processes for test environments * @param command - Command to execute * @param args - Arguments for the command * @param serverPath - Path to the server file * @param config - Security configuration * @returns Spawn options with security constraints */ export function validateSecureSpawn( command: string, args: string[], serverPath: string, config: SecureSpawnConfig = DEFAULT_TEST_SPAWN_CONFIG, ): { command: string; args: string[]; options: { stdio: ['pipe', 'pipe', 'pipe']; env: Record<string, string>; timeout: number; detached: false; shell: false; }; } { // Validate command is in allowlist if (!isStringInReadonlyArray(command, config.allowedCommands)) { throw new ProcessSpawnSecurityError( `Command '${command}' not in allowlist`, 'DISALLOWED_COMMAND', ); } // Validate server path is within expected locations const allowedServerPaths = ['src/mcp-stdio-server.ts', 'src/mcp-httpstream-server.ts']; const normalizedPath = serverPath.replace(/\\/g, '/'); if (!allowedServerPaths.some((allowed) => normalizedPath.endsWith(allowed))) { throw new ProcessSpawnSecurityError( `Server path '${serverPath}' not in allowlist`, 'DISALLOWED_PATH', ); } // Validate arguments const dangerousArgs = ['--allow-scripts', '--unsafe-perm', '--ignore-scripts=false']; const hasDangerousArgs = args.some((arg) => dangerousArgs.includes(arg)); if (hasDangerousArgs) { throw new ProcessSpawnSecurityError( `Dangerous arguments detected: ${args.join(' ')}`, 'DANGEROUS_ARGS', ); } // Validate all args are in allowlist or are safe paths const unsafeArgs = args.filter((arg) => { // Allow known safe arguments if (isStringInReadonlyArray(arg, config.allowedArgs)) { return false; } // Allow server paths if (allowedServerPaths.some((allowed) => arg.endsWith(allowed))) { return false; } // Allow relative paths starting with src/ if (arg.startsWith('src/') && !arg.includes('..')) { return false; } return true; }); if (unsafeArgs.length > 0) { throw new ProcessSpawnSecurityError( `Unsafe arguments detected: ${unsafeArgs.join(', ')}`, 'UNSAFE_ARGS', ); } return { command, args, options: { stdio: ['pipe', 'pipe', 'pipe'] as ['pipe', 'pipe', 'pipe'], env: { ...(Object.fromEntries( Object.entries(process.env).filter(([, value]) => value !== undefined), ) as Record<string, string>), ...config.env, }, timeout: config.timeout, detached: false, shell: false, // Never use shell to prevent injection }, }; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Jakedismo/KuzuMem-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server