UNPKG

mcp-sanitizer

Version:

Comprehensive security sanitization library for Model Context Protocol (MCP) servers with trusted security libraries

799 lines (709 loc) 25.1 kB
/** * Command Validator for MCP Sanitizer * * This module provides comprehensive validation and sanitization for command strings, * protecting against command injection attacks, dangerous commands, and shell metacharacters. * * Features: * - Command injection pattern detection * - Shell metacharacter validation * - Dangerous command blocking * - Command whitelist/blacklist support * - Argument validation and sanitization * - Cross-platform command handling * - Configurable validation rules * - Async validation support * * @example * const { CommandValidator } = require('./command'); * const validator = new CommandValidator(config); * * const result = await validator.validate('ls -la /home'); * if (result.isValid) { * console.log('Sanitized command:', result.sanitized); * } else { * console.error('Validation failed:', result.warnings); * } */ // const { validationUtils, stringUtils } = require('../../utils') // Unused - commented to fix ESLint const { commandInjection, detectAllPatterns, SEVERITY_LEVELS } = require('../../patterns') const shellQuote = require('shell-quote') /** * Command validation severity levels */ const SEVERITY = { LOW: 'low', MEDIUM: 'medium', HIGH: 'high', CRITICAL: 'critical' } /** * Default configuration for command validation */ const DEFAULT_CONFIG = { allowedCommands: [], // Empty array means all commands allowed (use with caution) blockedCommands: [ 'rm', 'del', 'format', 'mkfs', 'dd', 'fdisk', 'nc', 'netcat', 'telnet', 'ssh', 'ftp', 'wget', 'curl', 'lynx', 'links', 'eval', 'exec', 'system', 'popen', 'sudo', 'su', 'passwd', 'chmod', 'chown', 'mount', 'umount', 'kill', 'killall', 'reboot', 'shutdown', 'halt', 'init' ], allowShellMetacharacters: false, allowRedirection: false, allowPipes: false, allowBackgroundExecution: false, allowSubcommands: false, maxCommandLength: 1000, maxArguments: 20, maxArgumentLength: 500, allowEnvironmentVariables: false, strictMode: false, customPatterns: [], platformSpecific: { windows: { blockedCommands: ['del', 'format', 'diskpart', 'reg', 'sc', 'net', 'runas'], dangerousExtensions: ['.exe', '.bat', '.cmd', '.ps1', '.vbs', '.scr'] }, unix: { blockedCommands: ['rm', 'dd', 'mkfs', 'fdisk', 'mount', 'umount', 'sudo', 'su'], dangerousPaths: ['/bin/', '/sbin/', '/usr/bin/', '/usr/sbin/'] } } } /** * Shell metacharacters that can be dangerous */ const SHELL_METACHARACTERS = { dangerous: ['&', '|', ';', '`', '$', '(', ')', '{', '}', '[', ']', '<', '>', '*', '?'], redirection: ['>', '<', '>>', '<<'], pipes: ['|', '||', '&&'], background: ['&'], substitution: ['`', '$()'], globbing: ['*', '?', '[', ']', '{', '}'] } /** * Command injection patterns specific to this validator */ const INJECTION_PATTERNS = [ /[;&|`$(){}[\]<>*?]/, // Shell metacharacters /\|\s*(nc|netcat|telnet|ssh)\s+/i, // Network command pipes />\s*\/dev\/|<\s*\/dev\//, // Device redirection /\$\([^)]*\)|`[^`]*`/, // Command substitution /&&|\|\||;/, // Command chaining />\s*&\s*\d+|<\s*&\s*\d+/, // File descriptor redirection /\\\w+/, // Escape sequences /\${[^}]+}/, // Variable expansion /\s+-[^-\s]*e[^-\s]*\s+/ // Execute flags in various commands ] /** * Command Validator Class */ class CommandValidator { /** * Create a new command validator * @param {Object} config - Validation configuration */ constructor (config = {}) { this.config = { ...DEFAULT_CONFIG, ...config } this.platform = this._detectPlatform() } /** * Validate a command string * @param {string} command - The command to validate * @param {Object} options - Additional validation options * @returns {Promise<Object>} Validation result */ async validate (command, options = {}) { const result = { isValid: false, sanitized: null, warnings: [], severity: null, metadata: { originalCommand: command, parsedCommand: null, commandName: null, arguments: [], containsMetacharacters: false, detectedPatterns: [] } } try { // Basic input validation if (typeof command !== 'string') { result.warnings.push('Command must be a string') result.severity = SEVERITY.HIGH return result } if (!command || command.trim().length === 0) { result.warnings.push('Command cannot be empty') result.severity = SEVERITY.HIGH return result } // Check command length if (command.length > this.config.maxCommandLength) { result.warnings.push(`Command exceeds maximum length of ${this.config.maxCommandLength} characters`) result.severity = SEVERITY.MEDIUM return result } const trimmedCommand = command.trim() // Check for security patterns using command injection detector const injectionResult = commandInjection.detectCommandInjection(trimmedCommand) if (injectionResult.detected) { result.metadata.detectedPatterns = injectionResult.patterns result.warnings.push(`Command injection patterns detected: ${injectionResult.patterns.join(', ')}`) result.severity = this._mapSeverity(injectionResult.severity) if (injectionResult.severity === SEVERITY_LEVELS.CRITICAL) { return result } } // Run general pattern detection const patternResult = detectAllPatterns(trimmedCommand) if (patternResult.detected) { result.metadata.detectedPatterns.push(...patternResult.patterns) result.warnings.push(`Additional security patterns detected: ${patternResult.patterns.join(', ')}`) result.severity = this._getHigherSeverity(result.severity, this._mapSeverity(patternResult.severity)) } // Parse command into components const parseResult = this._parseCommand(trimmedCommand) if (!parseResult.isValid) { result.warnings.push(...parseResult.warnings) result.severity = this._getHigherSeverity(result.severity, parseResult.severity) return result } result.metadata.parsedCommand = parseResult.parsed result.metadata.commandName = parseResult.parsed.command result.metadata.arguments = parseResult.parsed.arguments // Validate shell metacharacters const metacharResult = this._validateMetacharacters(trimmedCommand) if (!metacharResult.isValid) { result.warnings.push(...metacharResult.warnings) result.severity = this._getHigherSeverity(result.severity, metacharResult.severity) result.metadata.containsMetacharacters = true if (metacharResult.severity === SEVERITY.CRITICAL) { return result } } // Validate command name against whitelist/blacklist const commandResult = this._validateCommandName(parseResult.parsed.command) if (!commandResult.isValid) { result.warnings.push(...commandResult.warnings) result.severity = this._getHigherSeverity(result.severity, commandResult.severity) if (commandResult.severity === SEVERITY.CRITICAL) { return result } } // Validate arguments const argsResult = this._validateArguments(parseResult.parsed.arguments) if (!argsResult.isValid) { result.warnings.push(...argsResult.warnings) result.severity = this._getHigherSeverity(result.severity, argsResult.severity) if (argsResult.severity === SEVERITY.CRITICAL) { return result } } // Platform-specific validation const platformResult = this._validatePlatformSpecific(parseResult.parsed, trimmedCommand) if (!platformResult.isValid) { result.warnings.push(...platformResult.warnings) result.severity = this._getHigherSeverity(result.severity, platformResult.severity) if (platformResult.severity === SEVERITY.CRITICAL) { return result } } // If we get here, the command is valid result.isValid = true result.sanitized = trimmedCommand // Set severity to lowest if there were warnings but command is still valid if (result.warnings.length === 0) { result.severity = null } else if (!result.severity) { result.severity = SEVERITY.LOW } } catch (error) { result.warnings.push(`Validation error: ${error.message}`) result.severity = SEVERITY.HIGH } return result } /** * Sanitize a command string * @param {string} command - The command to sanitize * @param {Object} options - Sanitization options * @returns {Promise<Object>} Sanitization result */ async sanitize (command, options = {}) { const validationResult = await this.validate(command, options) if (validationResult.isValid) { return validationResult } // Attempt to sanitize the command let sanitized = command const warnings = [...validationResult.warnings] try { // Basic sanitization sanitized = sanitized.trim() // Remove or escape dangerous metacharacters if (!this.config.allowShellMetacharacters) { // Remove dangerous metacharacters sanitized = sanitized.replace(/[;&|`$(){}[\]<>*?]/g, '') warnings.push('Removed shell metacharacters') } // Remove command chaining sanitized = sanitized.split(/[;&|]{1,2}/)[0].trim() if (sanitized !== command.trim()) { warnings.push('Removed command chaining') } // Remove redirection sanitized = sanitized.replace(/\s*[<>]+\s*[^\s]*/g, '') if (sanitized !== command.trim()) { warnings.push('Removed redirection operators') } // Remove background execution sanitized = sanitized.replace(/\s*&\s*$/, '') // Limit argument count const parts = sanitized.split(/\s+/) if (parts.length > this.config.maxArguments + 1) { // +1 for command name sanitized = parts.slice(0, this.config.maxArguments + 1).join(' ') warnings.push(`Limited arguments to ${this.config.maxArguments}`) } // Limit argument lengths const sanitizedParts = sanitized.split(/\s+/).map(part => { if (part.length > this.config.maxArgumentLength) { warnings.push(`Truncated argument '${part}' to maximum length`) return part.substring(0, this.config.maxArgumentLength) } return part }) sanitized = sanitizedParts.join(' ') // If command becomes empty or too short after sanitization, reject it if (!sanitized || sanitized.length < 2) { return { isValid: false, sanitized: null, warnings: [...warnings, 'Command became too short or empty after sanitization'], severity: SEVERITY.HIGH, metadata: { ...validationResult.metadata, wasSanitized: false, sanitizationFailed: true } } } // Re-validate the sanitized command const revalidationResult = await this.validate(sanitized, options) return { isValid: revalidationResult.isValid, sanitized: revalidationResult.isValid ? revalidationResult.sanitized : null, warnings: [...warnings, ...revalidationResult.warnings], severity: this._getHigherSeverity(validationResult.severity, revalidationResult.severity), metadata: { ...validationResult.metadata, ...revalidationResult.metadata, wasSanitized: true, sanitizationApplied: true } } } catch (error) { return { isValid: false, sanitized: null, warnings: [...warnings, `Sanitization failed: ${error.message}`], severity: SEVERITY.HIGH, metadata: { ...validationResult.metadata, wasSanitized: false, sanitizationError: error.message } } } } /** * Parse a command string into components * @param {string} command - Command to parse * @returns {Object} Parse result * @private */ _parseCommand (command) { const result = { isValid: true, warnings: [], severity: null, parsed: { command: null, arguments: [], fullCommand: command } } try { // Simple parsing - split by whitespace const parts = command.trim().split(/\s+/) if (parts.length === 0) { result.isValid = false result.warnings.push('Unable to parse command') result.severity = SEVERITY.HIGH return result } result.parsed.command = parts[0] result.parsed.arguments = parts.slice(1) // Check for dangerous command patterns in the first part const commandPart = result.parsed.command.toLowerCase() // Check for path traversal in command name if (commandPart.includes('..') || commandPart.includes('/./') || commandPart.includes('\\.\\')) { result.warnings.push('Path traversal detected in command name') result.severity = SEVERITY.HIGH } // Check for executable extensions (Windows) if (this.platform === 'windows') { const dangerousExts = this.config.platformSpecific.windows.dangerousExtensions for (const ext of dangerousExts) { if (commandPart.endsWith(ext)) { result.warnings.push(`Potentially dangerous executable extension: ${ext}`) result.severity = this._getHigherSeverity(result.severity, SEVERITY.MEDIUM) } } } } catch (error) { result.isValid = false result.warnings.push(`Command parsing failed: ${error.message}`) result.severity = SEVERITY.HIGH } return result } /** * Validate shell metacharacters * @param {string} command - Command to validate * @returns {Object} Validation result * @private */ _validateMetacharacters (command) { const result = { isValid: true, warnings: [], severity: null } // Check for dangerous metacharacters for (const char of SHELL_METACHARACTERS.dangerous) { if (command.includes(char)) { const charType = this._getMetacharacterType(char) if (!this._isMetacharacterAllowed(char, charType)) { result.isValid = false result.warnings.push(`Dangerous shell metacharacter detected: '${char}' (${charType})`) result.severity = this._getMetacharacterSeverity(char, charType) } else { result.warnings.push(`Warning: Shell metacharacter '${char}' detected but allowed`) result.severity = this._getHigherSeverity(result.severity, SEVERITY.LOW) } } } // Check for specific injection patterns for (const pattern of INJECTION_PATTERNS) { if (pattern.test(command)) { result.isValid = false result.warnings.push(`Command injection pattern detected: ${pattern.source}`) result.severity = SEVERITY.CRITICAL } } return result } /** * Get the type of a metacharacter * @param {string} char - Metacharacter to classify * @returns {string} Character type * @private */ _getMetacharacterType (char) { if (SHELL_METACHARACTERS.redirection.includes(char)) return 'redirection' if (SHELL_METACHARACTERS.pipes.includes(char)) return 'pipe' if (SHELL_METACHARACTERS.background.includes(char)) return 'background' if (SHELL_METACHARACTERS.substitution.includes(char)) return 'substitution' if (SHELL_METACHARACTERS.globbing.includes(char)) return 'globbing' return 'other' } /** * Check if a metacharacter is allowed based on configuration * @param {string} char - Metacharacter to check * @param {string} type - Character type * @returns {boolean} True if allowed * @private */ _isMetacharacterAllowed (char, type) { if (this.config.allowShellMetacharacters) return true switch (type) { case 'redirection': return this.config.allowRedirection case 'pipe': return this.config.allowPipes case 'background': return this.config.allowBackgroundExecution case 'substitution': return this.config.allowSubcommands default: return false } } /** * Get severity level for metacharacter * @param {string} char - Metacharacter * @param {string} type - Character type * @returns {string} Severity level * @private */ _getMetacharacterSeverity (char, type) { switch (type) { case 'substitution': return SEVERITY.CRITICAL case 'pipe': return char === '|' ? SEVERITY.HIGH : SEVERITY.CRITICAL case 'redirection': return SEVERITY.HIGH case 'background': return SEVERITY.HIGH default: return SEVERITY.MEDIUM } } /** * Validate command name against whitelist/blacklist * @param {string} commandName - Command name to validate * @returns {Object} Validation result * @private */ _validateCommandName (commandName) { const result = { isValid: true, warnings: [], severity: null } if (!commandName) { result.isValid = false result.warnings.push('Command name is empty') result.severity = SEVERITY.HIGH return result } const lowerCommand = commandName.toLowerCase() // Check blacklist first (higher priority) if (this.config.blockedCommands.includes(lowerCommand)) { result.isValid = false result.warnings.push(`Command '${commandName}' is blocked for security reasons`) result.severity = SEVERITY.CRITICAL return result } // Check whitelist if specified if (this.config.allowedCommands.length > 0) { if (!this.config.allowedCommands.includes(lowerCommand)) { result.isValid = false result.warnings.push(`Command '${commandName}' is not in the allowed list`) result.severity = SEVERITY.HIGH } } return result } /** * Validate command arguments * @param {string[]} args - Arguments to validate * @returns {Object} Validation result * @private */ _validateArguments (args) { const result = { isValid: true, warnings: [], severity: null } // Check argument count if (args.length > this.config.maxArguments) { result.warnings.push(`Too many arguments (${args.length} > ${this.config.maxArguments})`) result.severity = SEVERITY.MEDIUM } // Validate each argument for (let i = 0; i < args.length; i++) { const arg = args[i] // Check argument length if (arg.length > this.config.maxArgumentLength) { result.warnings.push(`Argument ${i + 1} exceeds maximum length (${this.config.maxArgumentLength})`) result.severity = this._getHigherSeverity(result.severity, SEVERITY.MEDIUM) } // Check for dangerous patterns in arguments const patternResult = detectAllPatterns(arg) if (patternResult.detected) { result.warnings.push(`Security patterns detected in argument ${i + 1}: ${patternResult.patterns.join(', ')}`) result.severity = this._getHigherSeverity(result.severity, this._mapSeverity(patternResult.severity)) } // Check for environment variables if not allowed if (!this.config.allowEnvironmentVariables && (arg.includes('$') || arg.includes('%'))) { result.warnings.push(`Environment variable detected in argument ${i + 1}`) result.severity = this._getHigherSeverity(result.severity, SEVERITY.MEDIUM) } } return result } /** * Platform-specific validation * @param {Object} parsed - Parsed command object * @param {string} command - Original command string * @returns {Object} Validation result * @private */ _validatePlatformSpecific (parsed, command) { const result = { isValid: true, warnings: [], severity: null } const platformConfig = this.config.platformSpecific[this.platform] if (!platformConfig) { return result // No platform-specific rules } // Check platform-specific blocked commands if (platformConfig.blockedCommands) { const lowerCommand = parsed.command.toLowerCase() if (platformConfig.blockedCommands.includes(lowerCommand)) { result.isValid = false result.warnings.push(`Command '${parsed.command}' is blocked on ${this.platform} platform`) result.severity = SEVERITY.CRITICAL } } // Unix-specific checks if (this.platform === 'unix' && platformConfig.dangerousPaths) { for (const path of platformConfig.dangerousPaths) { if (command.includes(path)) { result.warnings.push(`Dangerous path detected: ${path}`) result.severity = this._getHigherSeverity(result.severity, SEVERITY.HIGH) } } } return result } /** * Detect the current platform * @returns {string} Platform name * @private */ _detectPlatform () { if (process.platform === 'win32') { return 'windows' } return 'unix' // Covers Linux, macOS, etc. } /** * Map pattern detection severity to validator severity * @param {string} patternSeverity - Pattern detection severity * @returns {string} Validator severity * @private */ _mapSeverity (patternSeverity) { const mapping = { [SEVERITY_LEVELS.LOW]: SEVERITY.LOW, [SEVERITY_LEVELS.MEDIUM]: SEVERITY.MEDIUM, [SEVERITY_LEVELS.HIGH]: SEVERITY.HIGH, [SEVERITY_LEVELS.CRITICAL]: SEVERITY.CRITICAL } return mapping[patternSeverity] || SEVERITY.MEDIUM } /** * Get the higher severity between two severity levels * @param {string} current - Current severity * @param {string} newSeverity - New severity to compare * @returns {string} Higher severity * @private */ _getHigherSeverity (current, newSeverity) { if (!current) return newSeverity if (!newSeverity) return current const severityOrder = [SEVERITY.LOW, SEVERITY.MEDIUM, SEVERITY.HIGH, SEVERITY.CRITICAL] const currentIndex = severityOrder.indexOf(current) const newIndex = severityOrder.indexOf(newSeverity) return newIndex > currentIndex ? newSeverity : current } /** * Update validator configuration * @param {Object} newConfig - New configuration to merge */ updateConfig (newConfig) { this.config = { ...this.config, ...newConfig } } /** * Get current configuration * @returns {Object} Current configuration */ getConfig () { return { ...this.config } } /** * Safely quote command arguments using shell-quote library * @param {Array} args - Array of command and arguments * @returns {string} Safely quoted command string */ quote (args) { if (!Array.isArray(args)) { throw new Error('Arguments must be an array') } return shellQuote.quote(args) } /** * Parse a command string into an array using shell-quote library * @param {string} cmd - Command string to parse * @param {Object} env - Optional environment variables * @returns {Array} Parsed command array */ parse (cmd, env = {}) { if (typeof cmd !== 'string') { throw new Error('Command must be a string') } return shellQuote.parse(cmd, env) } /** * Build a safe command string from command and arguments * @param {string} command - The command to execute * @param {Array} args - Array of arguments * @returns {string} Safely constructed command string */ buildSafeCommand (command, args = []) { if (!command || typeof command !== 'string') { throw new Error('Command must be a non-empty string') } return this.quote([command, ...args]) } } /** * Create a command validator with default configuration * @param {Object} config - Optional configuration overrides * @returns {CommandValidator} New validator instance */ function createCommandValidator (config = {}) { return new CommandValidator(config) } /** * Quick validation function for simple use cases * @param {string} command - Command to validate * @param {Object} config - Optional configuration * @returns {Promise<Object>} Validation result */ async function validateCommand (command, config = {}) { const validator = new CommandValidator(config) return await validator.validate(command) } /** * Quick sanitization function for simple use cases * @param {string} command - Command to sanitize * @param {Object} config - Optional configuration * @returns {Promise<Object>} Sanitization result */ async function sanitizeCommand (command, config = {}) { const validator = new CommandValidator(config) return await validator.sanitize(command) } module.exports = { CommandValidator, createCommandValidator, validateCommand, sanitizeCommand, SEVERITY, DEFAULT_CONFIG, SHELL_METACHARACTERS, INJECTION_PATTERNS }