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
JavaScript
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
};