UNPKG

mcp-sanitizer

Version:

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

365 lines (318 loc) 10.4 kB
/** * Unified Parser Module - CVE-TBD-001 Fix * * This module provides a unified, immutable parsing entry point that ensures * ALL security validation uses the SAME normalized string representation. * * CRITICAL SECURITY FIX: * - Single normalization point prevents parser differential attacks * - Immutable string handling prevents TOCTOU vulnerabilities * - Comprehensive encoding detection and normalization * - Zero parser differential between security decoder and validators * * CVE-TBD-001: Parser Differential Vulnerability (CVSS 9.1) * Fix: All validation MUST use this unified parser, never original strings */ const { securityDecode } = require('./security-decoder'); /** * Immutable normalized string wrapper * Prevents accidental access to original strings */ class NormalizedString { constructor (original, normalized, metadata = {}) { // Make properties read-only and non-enumerable for security Object.defineProperty(this, '_original', { value: original, writable: false, enumerable: false, configurable: false }); Object.defineProperty(this, '_normalized', { value: normalized, writable: false, enumerable: false, configurable: false }); Object.defineProperty(this, '_metadata', { value: Object.freeze({ ...metadata }), writable: false, enumerable: false, configurable: false }); // Freeze the entire object to prevent modification Object.freeze(this); } /** * Get the normalized string - this is what ALL validators should use * @returns {string} The normalized, safe string */ toString () { return this._normalized; } /** * Get the normalized string explicitly * @returns {string} The normalized, safe string */ getNormalized () { return this._normalized; } /** * Get metadata about the normalization process (read-only) * @returns {Object} Immutable metadata */ getMetadata () { return this._metadata; } /** * Check if the string was modified during normalization * @returns {boolean} True if original string was changed */ wasNormalized () { return this._original !== this._normalized; } /** * Get original string - ONLY for logging/debugging, NEVER for validation * @returns {string} Original input string * @deprecated Use getNormalized() for all validation logic */ getOriginal () { // Console output removed for production called - use getNormalized() for validation') return this._original; } /** * Get string length (uses normalized string) * @returns {number} Length of normalized string */ get length () { return this._normalized.length; } /** * Enable basic string operations on normalized string */ valueOf () { return this._normalized; } // Proxy common string methods to normalized string includes (searchString, position) { return this._normalized.includes(searchString, position); } indexOf (searchString, position) { return this._normalized.indexOf(searchString, position); } match (regexp) { return this._normalized.match(regexp); } replace (searchValue, replaceValue) { // Return new NormalizedString with replaced content const replaced = this._normalized.replace(searchValue, replaceValue); return new NormalizedString(this._original, replaced, { ...this._metadata, wasModified: true, lastOperation: 'replace' }); } slice (start, end) { const sliced = this._normalized.slice(start, end); return new NormalizedString(this._original, sliced, { ...this._metadata, wasModified: true, lastOperation: 'slice' }); } split (separator, limit) { return this._normalized.split(separator, limit).map(part => new NormalizedString(part, part, { ...this._metadata, wasModified: true, lastOperation: 'split' }) ); } toLowerCase () { const lowered = this._normalized.toLowerCase(); return new NormalizedString(this._original, lowered, { ...this._metadata, wasModified: true, lastOperation: 'toLowerCase' }); } toUpperCase () { const uppered = this._normalized.toUpperCase(); return new NormalizedString(this._original, uppered, { ...this._metadata, wasModified: true, lastOperation: 'toUpperCase' }); } trim () { const trimmed = this._normalized.trim(); return new NormalizedString(this._original, trimmed, { ...this._metadata, wasModified: true, lastOperation: 'trim' }); } } /** * Unified parsing function - ENTRY POINT for all string validation * * This function MUST be used by ALL validators to ensure consistent * string processing and prevent parser differential attacks. * * @param {string} input - Raw input string * @param {Object} options - Parsing options * @returns {NormalizedString} Immutable normalized string wrapper */ function parseUnified (input, options = {}) { const { type = 'generic', // Input type for context-specific normalization strictMode = false, // allowOriginalAccess = false, // For backwards compatibility (DEPRECATED) - Unused logAccess = true // Log access to original strings } = options; if (typeof input !== 'string') { throw new Error('parseUnified: Input must be a string'); } // Apply comprehensive security decoding with enhanced options const decodeOptions = { decodeUnicode: true, decodeUrl: true, normalizePath: type === 'file_path' || type === 'path', stripDangerous: type === 'command', normalizeUnicode: true, maxIterations: 5, // Increased for thorough decoding // CVE-TBD-001 specific fixes checkDirectionalOverrides: true, checkNullBytes: true, checkMultipleEncoding: true, checkCyrillicHomographs: true, // Timing consistency removed maxEncodingDepth: 6 }; const decodeResult = securityDecode(input, decodeOptions); // Create metadata about the parsing process const metadata = { inputType: type, wasDecoded: decodeResult.wasDecoded, decodingSteps: decodeResult.decodingSteps || [], warnings: decodeResult.warnings || [], securityChecks: decodeResult.securityChecks || {}, iterations: decodeResult.iterations || 0, strictMode, parseTimestamp: Date.now(), // CVE-TBD-001 specific metadata parserDifferentialPrevented: true, unifiedParsingVersion: '1.0.0', immutableWrapper: true }; // Log any access to original string if enabled if (logAccess && process.env.NODE_ENV !== 'production') { metadata.accessLogging = true; } return new NormalizedString(input, decodeResult.decoded, metadata); } /** * Parse multiple strings with unified parsing * @param {string[]} inputs - Array of input strings * @param {Object} options - Parsing options * @returns {NormalizedString[]} Array of normalized strings */ function parseUnifiedBatch (inputs, options = {}) { if (!Array.isArray(inputs)) { throw new Error('parseUnifiedBatch: Inputs must be an array'); } return inputs.map(input => parseUnified(input, options)); } /** * Validate that a value is a NormalizedString (security check) * @param {*} value - Value to check * @returns {boolean} True if value is a NormalizedString */ function isNormalizedString (value) { return value instanceof NormalizedString; } /** * Extract normalized string safely (for legacy code migration) * @param {NormalizedString|string} value - Input value * @returns {string} Normalized string */ function extractNormalized (value) { if (isNormalizedString(value)) { return value.getNormalized(); } if (typeof value === 'string') { // Console output removed for production first') // For backwards compatibility, normalize on the fly return parseUnified(value).getNormalized(); } throw new Error('extractNormalized: Value must be string or NormalizedString'); } /** * Create a validator wrapper that ensures all inputs are normalized * @param {Function} validatorFunction - Original validator function * @returns {Function} Wrapped validator that enforces normalization */ function wrapValidator (validatorFunction) { return function wrappedValidator (input, ...args) { let normalizedInput; if (isNormalizedString(input)) { normalizedInput = input; } else if (typeof input === 'string') { // Auto-normalize for backwards compatibility but warn // Console output removed for production normalizedInput = parseUnified(input); } else { throw new Error('Validator input must be string or NormalizedString'); } // Call original function with normalized string return validatorFunction.call(this, normalizedInput.getNormalized(), ...args); }; } /** * Middleware to ensure request parameters are normalized * @param {Object} req - Express request object * @param {Object} res - Express response object * @param {Function} next - Next middleware function */ function unifiedParsingMiddleware (req, res, next) { // Normalize all string parameters const normalizeObject = (obj) => { const normalized = {}; for (const [key, value] of Object.entries(obj)) { if (typeof value === 'string') { normalized[key] = parseUnified(value); } else if (typeof value === 'object' && value !== null) { normalized[key] = normalizeObject(value); } else { normalized[key] = value; } } return normalized; }; // Store original for debugging if needed req._originalQuery = { ...req.query }; req._originalBody = req.body ? { ...req.body } : {}; req._originalParams = { ...req.params }; // Replace with normalized versions req.query = normalizeObject(req.query || {}); req.params = normalizeObject(req.params || {}); if (req.body && typeof req.body === 'object') { req.body = normalizeObject(req.body); } // Mark request as having unified parsing req._unifiedParsingApplied = true; req._parsingTimestamp = Date.now(); next(); } module.exports = { NormalizedString, parseUnified, parseUnifiedBatch, isNormalizedString, extractNormalized, wrapValidator, unifiedParsingMiddleware, // Constants PARSING_VERSION: '1.0.0', CVE_FIXED: 'CVE-TBD-001' };