UNPKG

ntfy-mcp-server

Version:

An MCP (Model Context Protocol) server designed to interact with the ntfy push notification service. It enables LLMs and AI agents to send notifications to your devices with extensive customization options.

368 lines (367 loc) 14.2 kB
import path from 'path'; import normalize from 'path-normalize'; import sanitizeHtml from 'sanitize-html'; import validator from 'validator'; import * as xssFilters from 'xss-filters'; import { BaseErrorCode, McpError } from '../types-global/errors.js'; import { logger } from './logger.js'; /** * Sanitization class for handling various input sanitization tasks */ export class Sanitization { /** * Private constructor to enforce singleton pattern */ constructor() { /** Default list of sensitive fields for sanitizing logs */ this.sensitiveFields = [ 'password', 'token', 'secret', 'key', 'apiKey', 'auth', 'credential', 'jwt', 'ssn', 'credit', 'card', 'cvv', 'authorization' ]; /** Default sanitize-html configuration */ this.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 }; logger.debug('Sanitization service initialized with modern libraries'); } /** * Get the singleton Sanitization instance * @returns Sanitization instance */ static getInstance() { if (!Sanitization.instance) { Sanitization.instance = new Sanitization(); } return Sanitization.instance; } /** * Set sensitive fields for log sanitization * @param fields Array of field names to consider sensitive */ setSensitiveFields(fields) { this.sensitiveFields = [...this.sensitiveFields, ...fields]; logger.debug('Updated sensitive fields list', { count: this.sensitiveFields.length }); } /** * Get the current list of sensitive fields * @returns Array of sensitive field names */ getSensitiveFields() { return [...this.sensitiveFields]; } /** * Sanitize HTML content using sanitize-html library * @param input HTML string to sanitize * @param config Optional custom sanitization config * @returns Sanitized HTML */ sanitizeHtml(input, config) { if (!input) return ''; // Create sanitize-html options from our config const options = { allowedTags: config?.allowedTags || this.defaultHtmlSanitizeConfig.allowedTags, allowedAttributes: config?.allowedAttributes || this.defaultHtmlSanitizeConfig.allowedAttributes, transformTags: config?.transformTags }; // Handle comments - if preserveComments is true, add '!--' to allowedTags if (config?.preserveComments || this.defaultHtmlSanitizeConfig.preserveComments) { options.allowedTags = [...(options.allowedTags || []), '!--']; } return sanitizeHtml(input, options); } /** * Sanitize string input based on context * @param input String to sanitize * @param options Sanitization options * @returns Sanitized string */ sanitizeString(input, options = {}) { if (!input) return ''; // Handle based on context switch (options.context) { case 'html': // Use sanitize-html with custom options return this.sanitizeHtml(input, { allowedTags: options.allowedTags, allowedAttributes: options.allowedAttributes ? this.convertAttributesFormat(options.allowedAttributes) : undefined }); case 'attribute': // Use xss-filters for HTML attributes return xssFilters.inHTMLData(input); case 'url': // Validate and sanitize URL if (!validator.isURL(input, { protocols: ['http', 'https'], require_protocol: true })) { return ''; } return validator.trim(input); case 'javascript': // Reject any attempt to sanitize JavaScript throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'JavaScript sanitization not supported through string sanitizer', { input: input.substring(0, 50) }); case 'text': default: // Use XSS filters for basic text return xssFilters.inHTMLData(input); } } /** * Sanitize URL with robust validation and sanitization * @param input URL to sanitize * @param allowedProtocols Allowed URL protocols * @returns Sanitized URL */ sanitizeUrl(input, allowedProtocols = ['http', 'https']) { try { // First validate the URL format if (!validator.isURL(input, { protocols: allowedProtocols, require_protocol: true })) { throw new Error('Invalid URL format'); } // Double-check no javascript: protocol sneaked in if (input.toLowerCase().includes('javascript:')) { throw new Error('JavaScript protocol not allowed'); } // Sanitize and return return validator.trim(xssFilters.uriInHTMLData(input)); } catch (error) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'Invalid URL format', { input, error: error instanceof Error ? error.message : String(error) }); } } /** * Sanitize file paths to prevent path traversal attacks * @param input Path to sanitize * @param options Options for path sanitization * @returns Sanitized and normalized path */ sanitizePath(input, options = {}) { try { if (!input) { throw new Error('Empty path'); } // Apply path normalization (resolves '..' and '.' segments properly) let normalized = normalize(input); // Convert backslashes to forward slashes if toPosix is true if (options.toPosix) { normalized = normalized.replace(/\\/g, '/'); } // Handle absolute paths based on allowAbsolute option if (!options.allowAbsolute && path.isAbsolute(normalized)) { // Remove leading slash or drive letter to make it relative normalized = normalized.replace(/^(?:[A-Za-z]:)?[/\\]/, ''); } // If rootDir is specified, ensure the path doesn't escape it if (options.rootDir) { const rootDir = path.resolve(options.rootDir); // Resolve the normalized path against the root dir const fullPath = path.resolve(rootDir, normalized); // More robust check for path traversal if (!fullPath.startsWith(rootDir + path.sep) && fullPath !== rootDir) { throw new Error('Path traversal detected'); } // Return the path relative to the root return path.relative(rootDir, fullPath); } // Final validation - ensure the path doesn't contain suspicious patterns if (normalized.includes('\0') || normalized.match(/\\\\[.?]|\.\.\\/)) { throw new Error('Invalid path characters detected'); } return normalized; } catch (error) { // Log the error for debugging logger.warn('Path sanitization error', { input, error: error instanceof Error ? error.message : String(error) }); // Return a safe default in case of errors throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'Invalid or unsafe path', { input }); } } /** * Sanitize a JSON string * @param input JSON string to sanitize * @param maxSize Maximum allowed size in bytes * @returns Parsed and sanitized object */ sanitizeJson(input, maxSize) { try { // Check size limit if specified if (maxSize !== undefined && input.length > maxSize) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, `JSON exceeds maximum allowed size of ${maxSize} bytes`, { size: input.length, maxSize }); } // Validate JSON format if (!validator.isJSON(input)) { throw new Error('Invalid JSON format'); } return JSON.parse(input); } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'Invalid JSON format', { input: input.length > 100 ? `${input.substring(0, 100)}...` : input }); } } /** * Ensure input is within a numeric range * @param input Number to validate * @param min Minimum allowed value * @param max Maximum allowed value * @returns Sanitized number within range */ sanitizeNumber(input, min, max) { let value; // Handle string input if (typeof input === 'string') { if (!validator.isNumeric(input)) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'Invalid number format', { input }); } value = parseFloat(input); } else { value = input; } if (isNaN(value)) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'Invalid number format', { input }); } if (min !== undefined && value < min) { value = min; } if (max !== undefined && value > max) { value = max; } return value; } /** * Sanitize input for logging to protect sensitive information * @param input Input to sanitize * @returns Sanitized input safe for logging */ sanitizeForLogging(input) { if (!input || typeof input !== 'object') { return input; } // Create a deep copy to avoid modifying the original const sanitized = Array.isArray(input) ? [...input] : { ...input }; // Recursively sanitize the object this.redactSensitiveFields(sanitized); return sanitized; } /** * Private helper to convert attribute format from record to sanitize-html format */ convertAttributesFormat(attrs) { const result = {}; for (const [tag, attributes] of Object.entries(attrs)) { result[tag] = attributes; } return result; } /** * Recursively redact sensitive fields in an object */ redactSensitiveFields(obj) { if (!obj || typeof obj !== 'object') { return; } // Handle arrays if (Array.isArray(obj)) { obj.forEach(item => this.redactSensitiveFields(item)); return; } // Handle regular objects for (const [key, value] of Object.entries(obj)) { // Check if this key matches any sensitive field pattern const isSensitive = this.sensitiveFields.some(field => key.toLowerCase().includes(field.toLowerCase())); if (isSensitive) { // Mask sensitive value obj[key] = '[REDACTED]'; } else if (value && typeof value === 'object') { // Recursively process nested objects this.redactSensitiveFields(value); } } } } // Create and export singleton instance export const sanitization = Sanitization.getInstance(); // Export the input sanitization object with convenience functions export const sanitizeInput = { /** * Remove potentially dangerous characters from strings based on context * @param input String to sanitize * @param options Sanitization options for context-specific handling * @returns Sanitized string */ string: (input, options = {}) => sanitization.sanitizeString(input, options), /** * Sanitize HTML to prevent XSS * @param input HTML string to sanitize * @param config Optional custom sanitization config * @returns Sanitized HTML */ html: (input, config) => sanitization.sanitizeHtml(input, config), /** * Sanitize URLs * @param input URL to sanitize * @param allowedProtocols Allowed URL protocols * @returns Sanitized URL */ url: (input, allowedProtocols = ['http', 'https']) => sanitization.sanitizeUrl(input, allowedProtocols), /** * Sanitize file paths to prevent path traversal attacks * @param input Path to sanitize * @param options Options for path sanitization * @returns Sanitized and normalized path */ path: (input, options = {}) => sanitization.sanitizePath(input, options), /** * Sanitize a JSON string * @param input JSON string to sanitize * @param maxSize Maximum allowed size in bytes * @returns Parsed and sanitized object */ json: (input, maxSize) => sanitization.sanitizeJson(input, maxSize), /** * Ensure input is within a numeric range * @param input Number to validate * @param min Minimum allowed value * @param max Maximum allowed value * @returns Sanitized number within range */ number: (input, min, max) => sanitization.sanitizeNumber(input, min, max) }; /** * Sanitize input for logging to protect sensitive information * @param input Input to sanitize * @returns Sanitized input safe for logging */ export const sanitizeInputForLogging = (input) => sanitization.sanitizeForLogging(input); // Export default export default { sanitization, sanitizeInput, sanitizeInputForLogging };