UNPKG

@nvhien20vn/git-mcp-server

Version:
374 lines (373 loc) 16.7 kB
import path from 'path'; import sanitizeHtml from 'sanitize-html'; import validator from 'validator'; import { BaseErrorCode, McpError } from '../../types-global/errors.js'; // Import utils from the main barrel file (logger from ../internal/logger.js) import { logger } from '../index.js'; /** * Sanitization class for handling various input sanitization tasks. * Provides methods to clean and validate strings, HTML, URLs, paths, JSON, and numbers. */ export class Sanitization { static instance; /** Default list of sensitive fields for sanitizing logs */ sensitiveFields = [ 'password', 'token', 'secret', 'key', 'apiKey', 'auth', 'credential', 'jwt', 'ssn', 'credit', 'card', 'cvv', 'authorization' ]; /** Default sanitize-html configuration */ defaultHtmlSanitizeConfig = { allowedTags: [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol', 'li', 'b', 'i', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'pre' ], allowedAttributes: { 'a': ['href', 'name', 'target'], 'img': ['src', 'alt', 'title', 'width', 'height'], '*': ['class', 'id', 'style'] }, preserveComments: false }; /** * Private constructor to enforce singleton pattern. */ constructor() { // Constructor intentionally left blank for singleton. } /** * Get the singleton Sanitization instance. * @returns {Sanitization} The singleton instance. */ static getInstance() { if (!Sanitization.instance) { Sanitization.instance = new Sanitization(); } return Sanitization.instance; } /** * Set sensitive fields for log sanitization. These fields will be redacted when * `sanitizeForLogging` is called. * @param {string[]} fields - Array of field names to consider sensitive. */ setSensitiveFields(fields) { this.sensitiveFields = [...new Set([...this.sensitiveFields, ...fields])]; // Ensure uniqueness logger.debug('Updated sensitive fields list', { count: this.sensitiveFields.length }); } /** * Get the current list of sensitive fields used for log sanitization. * @returns {string[]} Array of sensitive field names. */ getSensitiveFields() { return [...this.sensitiveFields]; } /** * Sanitize HTML content using the `sanitize-html` library. * Removes potentially malicious tags and attributes. * @param {string} input - HTML string to sanitize. * @param {HtmlSanitizeConfig} [config] - Optional custom sanitization configuration. * @returns {string} Sanitized HTML string. */ sanitizeHtml(input, config) { if (!input) return ''; const effectiveConfig = { ...this.defaultHtmlSanitizeConfig, ...config }; const options = { allowedTags: effectiveConfig.allowedTags, allowedAttributes: effectiveConfig.allowedAttributes, transformTags: effectiveConfig.transformTags }; if (effectiveConfig.preserveComments) { options.allowedTags = [...(options.allowedTags || []), '!--']; } return sanitizeHtml(input, options); } /** * Sanitize string input based on context. * * **Important:** Using `context: 'javascript'` is explicitly disallowed and will throw an `McpError`. * This is a security measure to prevent accidental execution or ineffective sanitization of JavaScript code. * * @param {string} input - String to sanitize. * @param {SanitizeStringOptions} [options={}] - Sanitization options. * @returns {string} Sanitized string. * @throws {McpError} If `context: 'javascript'` is used. */ sanitizeString(input, options = {}) { if (!input) return ''; switch (options.context) { case 'html': return this.sanitizeHtml(input, { allowedTags: options.allowedTags, allowedAttributes: options.allowedAttributes ? this.convertAttributesFormat(options.allowedAttributes) : undefined }); case 'attribute': return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} }); case 'url': if (!validator.isURL(input, { protocols: ['http', 'https'], require_protocol: true })) { logger.warning('Invalid URL detected during string sanitization', { input }); return ''; } return validator.trim(input); case 'javascript': logger.error('Attempted JavaScript sanitization via sanitizeString', { input: input.substring(0, 50) }); throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'JavaScript sanitization not supported through string sanitizer'); case 'text': default: return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} }); } } /** * Sanitize URL with robust validation. * Ensures the URL uses allowed protocols and is well-formed. * @param {string} input - URL to sanitize. * @param {string[]} [allowedProtocols=['http', 'https']] - Allowed URL protocols. * @returns {string} Sanitized URL. * @throws {McpError} If URL is invalid or uses a disallowed protocol. */ sanitizeUrl(input, allowedProtocols = ['http', 'https']) { try { if (!validator.isURL(input, { protocols: allowedProtocols, require_protocol: true })) { throw new Error('Invalid URL format or protocol'); } const lowerInput = input.toLowerCase().trim(); if (lowerInput.startsWith('javascript:')) { // Double-check against javascript: throw new Error('JavaScript protocol not allowed'); } return validator.trim(input); } catch (error) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, error instanceof Error ? error.message : 'Invalid URL format', { input }); } } /** * Sanitizes a file path to prevent path traversal and other common attacks. * Normalizes the path, optionally converts to POSIX style, and can restrict * the path to a root directory. * * @param {string} input - The file path to sanitize. * @param {PathSanitizeOptions} [options={}] - Options to control sanitization behavior. * @returns {SanitizedPathInfo} An object containing the sanitized path and metadata about the sanitization process. * @throws {McpError} If the path is invalid, unsafe (e.g., contains null bytes, attempts traversal). */ sanitizePath(input, options = {}) { const originalInput = input; const effectiveOptions = { toPosix: options.toPosix ?? false, allowAbsolute: options.allowAbsolute ?? false, rootDir: options.rootDir }; let wasAbsoluteInitially = false; let convertedToRelative = false; try { if (!input || typeof input !== 'string') { throw new Error('Invalid path input: must be a non-empty string'); } let normalized = path.normalize(input); wasAbsoluteInitially = path.isAbsolute(normalized); if (normalized.includes('\0')) { throw new Error('Path contains null byte'); } if (effectiveOptions.toPosix) { normalized = normalized.replace(/\\/g, '/'); } if (!effectiveOptions.allowAbsolute && path.isAbsolute(normalized)) { // Original path was absolute, but absolute paths are not allowed. // Convert to relative by stripping leading slash or drive letter. normalized = normalized.replace(/^(?:[A-Za-z]:)?[/\\]+/, ''); convertedToRelative = true; } let finalSanitizedPath; if (effectiveOptions.rootDir) { const rootDirResolved = path.resolve(effectiveOptions.rootDir); // If 'normalized' is absolute (and allowed), path.resolve uses it as the base. // If 'normalized' is relative, it's resolved against 'rootDirResolved'. const fullPath = path.resolve(rootDirResolved, normalized); if (!fullPath.startsWith(rootDirResolved + path.sep) && fullPath !== rootDirResolved) { throw new Error('Path traversal detected (escapes rootDir)'); } // Path is within rootDir, return it relative to rootDir. finalSanitizedPath = path.relative(rootDirResolved, fullPath); // Ensure empty string result from path.relative (if fullPath equals rootDirResolved) becomes '.' finalSanitizedPath = finalSanitizedPath === '' ? '.' : finalSanitizedPath; } else { // No rootDir specified if (path.isAbsolute(normalized)) { if (effectiveOptions.allowAbsolute) { // Absolute path is allowed and no rootDir to constrain it. finalSanitizedPath = normalized; } else { // Should not happen if logic above is correct (already made relative or was originally relative) // but as a safeguard: throw new Error('Absolute path encountered when not allowed and not rooted'); } } else { // Path is relative and no rootDir if (normalized.includes('..')) { const resolvedPath = path.resolve(normalized); // Resolves relative to CWD const currentWorkingDir = path.resolve('.'); if (!resolvedPath.startsWith(currentWorkingDir)) { throw new Error('Relative path traversal detected (escapes CWD)'); } } finalSanitizedPath = normalized; } } return { sanitizedPath: finalSanitizedPath, originalInput, wasAbsolute: wasAbsoluteInitially, convertedToRelative, optionsUsed: effectiveOptions }; } catch (error) { logger.warning('Path sanitization error', { input: originalInput, options: effectiveOptions, error: error instanceof Error ? error.message : String(error) }); throw new McpError(BaseErrorCode.VALIDATION_ERROR, error instanceof Error ? error.message : 'Invalid or unsafe path', { input: originalInput } // Provide original input in error details ); } } /** * Sanitize a JSON string. Validates format and optionally checks size. * @template T - The expected type of the parsed JSON object. * @param {string} input - JSON string to sanitize. * @param {number} [maxSize] - Maximum allowed size in bytes. * @returns {T} Parsed and sanitized object. * @throws {McpError} If JSON is invalid, too large, or input is not a string. */ sanitizeJson(input, maxSize) { try { if (typeof input !== 'string') { throw new Error('Invalid input: expected a JSON string'); } if (maxSize !== undefined && Buffer.byteLength(input, 'utf8') > maxSize) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, `JSON exceeds maximum allowed size of ${maxSize} bytes`, { size: Buffer.byteLength(input, 'utf8'), maxSize }); } const parsed = JSON.parse(input); // Optional: Add recursive sanitization of parsed object values if needed // this.sanitizeObjectRecursively(parsed); return parsed; } catch (error) { if (error instanceof McpError) throw error; throw new McpError(BaseErrorCode.VALIDATION_ERROR, error instanceof Error ? error.message : 'Invalid JSON format', { input: input.length > 100 ? `${input.substring(0, 100)}...` : input }); } } /** * Ensure input is a valid number and optionally within a numeric range. * Clamps the number to the range if min/max are provided and value is outside. * @param {number | string} input - Number or string to validate. * @param {number} [min] - Minimum allowed value (inclusive). * @param {number} [max] - Maximum allowed value (inclusive). * @returns {number} Sanitized number. * @throws {McpError} If input is not a valid number or parsable string. */ sanitizeNumber(input, min, max) { let value; if (typeof input === 'string') { if (!validator.isNumeric(input.trim())) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'Invalid number format', { input }); } value = parseFloat(input.trim()); } else if (typeof input === 'number') { value = input; } else { throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'Invalid input type: expected number or string', { input: String(input) }); } if (isNaN(value) || !isFinite(value)) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'Invalid number value (NaN or Infinity)', { input }); } let clamped = false; if (min !== undefined && value < min) { value = min; clamped = true; } if (max !== undefined && value > max) { value = max; clamped = true; } if (clamped) { logger.debug('Number clamped to range', { input, min, max, finalValue: value }); } return value; } /** * Sanitize input for logging to protect sensitive information. * Deep clones the input and redacts fields matching `this.sensitiveFields`. * @param {unknown} input - Input to sanitize. * @returns {unknown} Sanitized input safe for logging. */ sanitizeForLogging(input) { try { if (!input || typeof input !== 'object') { return input; } const clonedInput = typeof structuredClone === 'function' ? structuredClone(input) : JSON.parse(JSON.stringify(input)); // Fallback for older Node versions this.redactSensitiveFields(clonedInput); return clonedInput; } catch (error) { logger.error('Error during log sanitization', { error: error instanceof Error ? error.message : String(error) }); return '[Log Sanitization Failed]'; } } /** * Private helper to convert attribute format for sanitize-html. */ convertAttributesFormat(attrs) { return attrs; } /** * Recursively redact sensitive fields in an object or array. * Modifies the object in place. * @param {unknown} obj - The object or array to redact. */ redactSensitiveFields(obj) { if (!obj || typeof obj !== 'object') { return; } if (Array.isArray(obj)) { obj.forEach(item => { if (item && typeof item === 'object') { this.redactSensitiveFields(item); } }); return; } for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { const value = obj[key]; const isSensitive = this.sensitiveFields.some(field => key.toLowerCase().includes(field.toLowerCase())); if (isSensitive) { obj[key] = '[REDACTED]'; } else if (value && typeof value === 'object') { this.redactSensitiveFields(value); } } } } } // Create and export singleton instance export const sanitization = Sanitization.getInstance(); /** * Convenience function to sanitize input for logging. * @param {unknown} input - Input to sanitize. * @returns {unknown} Sanitized input safe for logging. */ export const sanitizeInputForLogging = (input) => sanitization.sanitizeForLogging(input);