UNPKG

mcp-sanitizer

Version:

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

556 lines (491 loc) 16.5 kB
/** * File Path Validator for MCP Sanitizer * * This module provides comprehensive validation and sanitization for file paths, * protecting against directory traversal attacks, access to restricted system * directories, and invalid file extensions. * * Features: * - Directory traversal detection and prevention * - System directory access restriction * - File extension validation * - Cross-platform path handling * - Configurable validation rules * - Async validation support * * @example * const { FilePathValidator } = require('./file-path'); * const validator = new FilePathValidator(config); * * const result = await validator.validate('/safe/path/file.txt'); * if (result.isValid) { * console.log('Sanitized path:', result.sanitized); * } else { * console.error('Validation failed:', result.warnings); * } */ const path = require('path') // const { validationUtils } = require('../../utils') // Unused - commented to fix ESLint const { detectAllPatterns, SEVERITY_LEVELS } = require('../../patterns') const sanitizeFilename = require('sanitize-filename') const pathIsInside = require('path-is-inside') /** * File path validation severity levels */ const SEVERITY = { LOW: 'low', MEDIUM: 'medium', HIGH: 'high', CRITICAL: 'critical' } /** * Default configuration for file path validation */ const DEFAULT_CONFIG = { allowedExtensions: ['.txt', '.json', '.csv', '.md', '.log'], blockedExtensions: ['.exe', '.bat', '.cmd', '.ps1', '.sh', '.scr'], allowAbsolutePaths: false, allowRelativePaths: true, maxPathLength: 260, // Windows MAX_PATH limit allowSystemDirectories: false, customDangerousPaths: [], normalizeBeforeValidation: true, strictMode: false } /** * System directories that are typically restricted */ const DANGEROUS_PATHS = { unix: [ '/etc/', '/proc/', '/sys/', '/dev/', '/root/', '/boot/', '/var/log/', '/usr/bin/', '/usr/sbin/', '/sbin/', '/bin/' ], windows: [ 'C:\\Windows\\', 'C:\\System32\\', 'C:\\Program Files\\', 'C:\\Program Files (x86)\\', 'C:\\ProgramData\\', 'C:\\Users\\Default\\', 'C:\\Boot\\', 'C:\\System Volume Information\\' ] } /** * File Path Validator Class */ class FilePathValidator { /** * Create a new file path validator * @param {Object} config - Validation configuration */ constructor (config = {}) { this.config = { ...DEFAULT_CONFIG, ...config } this.dangerousPaths = [ ...DANGEROUS_PATHS.unix, ...DANGEROUS_PATHS.windows, ...this.config.customDangerousPaths ] } /** * Validate a file path * @param {string} filePath - The file path to validate * @param {Object} options - Additional validation options * @returns {Promise<Object>} Validation result */ async validate (filePath, options = {}) { const result = { isValid: false, sanitized: null, warnings: [], severity: null, metadata: { originalPath: filePath, normalizedPath: null, extension: null, isAbsolute: false, detectedPatterns: [] } } try { // Basic input validation if (typeof filePath !== 'string') { result.warnings.push('File path must be a string') result.severity = SEVERITY.HIGH return result } if (!filePath || filePath.trim().length === 0) { result.warnings.push('File path cannot be empty') result.severity = SEVERITY.HIGH return result } // Check path length if (filePath.length > this.config.maxPathLength) { result.warnings.push(`File path exceeds maximum length of ${this.config.maxPathLength} characters`) result.severity = SEVERITY.MEDIUM return result } // Normalize path if configured let normalizedPath = filePath if (this.config.normalizeBeforeValidation) { normalizedPath = path.normalize(filePath) result.metadata.normalizedPath = normalizedPath } // Check for security patterns const patternResult = detectAllPatterns(normalizedPath) if (patternResult.detected) { result.metadata.detectedPatterns = patternResult.patterns result.warnings.push(`Security patterns detected: ${patternResult.patterns.join(', ')}`) result.severity = this._mapSeverity(patternResult.severity) if (patternResult.severity === SEVERITY_LEVELS.CRITICAL) { return result } } // Check for directory traversal const traversalResult = this._checkDirectoryTraversal(normalizedPath) if (!traversalResult.isValid) { result.warnings.push(...traversalResult.warnings) result.severity = this._getHigherSeverity(result.severity, SEVERITY.CRITICAL) return result } // Check absolute/relative path restrictions const isAbsolute = path.isAbsolute(normalizedPath) result.metadata.isAbsolute = isAbsolute if (isAbsolute && !this.config.allowAbsolutePaths) { result.warnings.push('Absolute paths are not allowed') result.severity = this._getHigherSeverity(result.severity, SEVERITY.HIGH) return result } if (!isAbsolute && !this.config.allowRelativePaths) { result.warnings.push('Relative paths are not allowed') result.severity = this._getHigherSeverity(result.severity, SEVERITY.HIGH) return result } // Check for access to dangerous system directories const systemDirResult = this._checkSystemDirectories(normalizedPath) if (!systemDirResult.isValid) { result.warnings.push(...systemDirResult.warnings) result.severity = this._getHigherSeverity(result.severity, SEVERITY.CRITICAL) return result } // Check file extension const extension = path.extname(normalizedPath).toLowerCase() result.metadata.extension = extension const extensionResult = this._checkFileExtension(extension) if (!extensionResult.isValid) { result.warnings.push(...extensionResult.warnings) result.severity = this._getHigherSeverity(result.severity, extensionResult.severity) if (extensionResult.severity === SEVERITY.CRITICAL) { return result } } // If we get here, the path is valid result.isValid = true result.sanitized = normalizedPath // Set severity to lowest if there were warnings but path 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 file path * @param {string} filePath - The file path to sanitize * @param {Object} options - Sanitization options * @returns {Promise<Object>} Sanitization result */ async sanitize (filePath, options = {}) { const validationResult = await this.validate(filePath, options) if (validationResult.isValid) { return validationResult } // Attempt to sanitize the path let sanitized = filePath const warnings = [...validationResult.warnings] try { // Remove dangerous characters sanitized = sanitized.replace(/[<>:"|?*]/g, '') // Handle directory traversal by removing ../ patterns sanitized = sanitized.replace(/\.\./g, '') // Normalize path separators sanitized = sanitized.replace(/[/\\]+/g, path.sep) // Remove leading/trailing whitespace sanitized = sanitized.trim() // If path becomes empty after sanitization, use a safe default if (!sanitized) { sanitized = 'sanitized_file.txt' warnings.push('Path was empty after sanitization, using default filename') } // Re-validate the sanitized path 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, 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 } } } } /** * Check for directory traversal attacks * @param {string} filePath - The file path to check * @returns {Object} Check result * @private */ _checkDirectoryTraversal (filePath) { const result = { isValid: true, warnings: [] } // Check for various directory traversal patterns const traversalPatterns = [ /\.\./, // Standard directory traversal /%2e%2e/i, // URL encoded .. /%252e%252e/i, // Double URL encoded .. /\.%2e/i, // Mixed encoding /%2e\./i, // Mixed encoding /\.\\\.\./, // Windows specific /\.\/%2e%2e/i // Mixed path separators ] for (const pattern of traversalPatterns) { if (pattern.test(filePath)) { result.isValid = false result.warnings.push(`Directory traversal pattern detected: ${pattern.source}`) } } // Additional check for normalized path containing .. if (filePath.includes('..')) { result.isValid = false result.warnings.push('Directory traversal detected in normalized path') } return result } /** * Check for access to system directories * @param {string} filePath - The file path to check * @returns {Object} Check result * @private */ _checkSystemDirectories (filePath) { const result = { isValid: true, warnings: [] } if (this.config.allowSystemDirectories) { return result } const lowerPath = filePath.toLowerCase() for (const dangerousPath of this.dangerousPaths) { const lowerDangerousPath = dangerousPath.toLowerCase() if (lowerPath.startsWith(lowerDangerousPath)) { result.isValid = false result.warnings.push(`Access to system directory not allowed: ${dangerousPath}`) } } return result } /** * Check file extension against allowed/blocked lists * @param {string} extension - The file extension to check * @returns {Object} Check result * @private */ _checkFileExtension (extension) { const result = { isValid: true, warnings: [], severity: null } if (!extension) { return result // No extension is generally allowed } // Check blocked extensions first (higher priority) if (this.config.blockedExtensions.includes(extension)) { result.isValid = false result.warnings.push(`File extension ${extension} is blocked for security reasons`) result.severity = SEVERITY.CRITICAL return result } // Check allowed extensions if specified if (this.config.allowedExtensions && this.config.allowedExtensions.length > 0) { if (!this.config.allowedExtensions.includes(extension)) { result.isValid = false result.warnings.push(`File extension ${extension} is not in the allowed list: ${this.config.allowedExtensions.join(', ')}`) result.severity = SEVERITY.MEDIUM } } return result } /** * 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 } this.dangerousPaths = [ ...DANGEROUS_PATHS.unix, ...DANGEROUS_PATHS.windows, ...this.config.customDangerousPaths ] } /** * Get current configuration * @returns {Object} Current configuration */ getConfig () { return { ...this.config } } /** * Sanitize a filename using sanitize-filename library * @param {string} filename - Filename to sanitize * @param {Object} options - Sanitization options * @returns {string} Sanitized filename */ sanitizeFilename (filename, options = {}) { const defaultOptions = { replacement: '_' } return sanitizeFilename(filename, { ...defaultOptions, ...options }) } /** * Check if a path is inside another path using path-is-inside * @param {string} childPath - Path to check * @param {string} parentPath - Parent path * @returns {boolean} True if childPath is inside parentPath */ isPathInside (childPath, parentPath) { try { // Resolve paths to handle relative paths const resolvedChild = path.resolve(childPath) const resolvedParent = path.resolve(parentPath) return pathIsInside(resolvedChild, resolvedParent) } catch (error) { // If paths cannot be resolved, consider it unsafe return false } } /** * Check if a path is safe within allowed base paths * @param {string} filePath - Path to check * @param {string[]} allowedPaths - Array of allowed base paths * @returns {boolean} True if path is within any allowed path */ isPathSafe (filePath, allowedPaths = []) { if (!allowedPaths.length) { // If no allowed paths specified, check if it's not in dangerous paths return !this._isInDangerousPaths(filePath) } // Check if path is inside any allowed path return allowedPaths.some(allowedPath => { return this.isPathInside(filePath, allowedPath) }) } /** * Extract and sanitize just the filename from a path * @param {string} filePath - Full file path * @returns {string} Sanitized filename */ extractSafeFilename (filePath) { const filename = path.basename(filePath) return this.sanitizeFilename(filename) } } /** * Create a file path validator with default configuration * @param {Object} config - Optional configuration overrides * @returns {FilePathValidator} New validator instance */ function createFilePathValidator (config = {}) { return new FilePathValidator(config) } /** * Quick validation function for simple use cases * @param {string} filePath - File path to validate * @param {Object} config - Optional configuration * @returns {Promise<Object>} Validation result */ async function validateFilePath (filePath, config = {}) { const validator = new FilePathValidator(config) return await validator.validate(filePath) } /** * Quick sanitization function for simple use cases * @param {string} filePath - File path to sanitize * @param {Object} config - Optional configuration * @returns {Promise<Object>} Sanitization result */ async function sanitizeFilePath (filePath, config = {}) { const validator = new FilePathValidator(config) return await validator.sanitize(filePath) } module.exports = { FilePathValidator, createFilePathValidator, validateFilePath, sanitizeFilePath, SEVERITY, DEFAULT_CONFIG, DANGEROUS_PATHS }