mcp-sanitizer
Version:
Comprehensive security sanitization library for Model Context Protocol (MCP) servers with trusted security libraries
638 lines (565 loc) • 19 kB
JavaScript
/**
* Fastify Plugin for MCP Sanitizer
*
* This plugin provides comprehensive request sanitization for Fastify applications
* serving MCP (Model Context Protocol) endpoints. It follows Fastify's plugin
* architecture and provides async-first sanitization with proper error handling.
*
* Features:
* - Fastify-native plugin architecture with proper registration
* - Async-first request/response sanitization
* - Comprehensive error handling and logging
* - MCP-specific tool execution patterns
* - Schema validation integration
* - Request/response hooks integration
* - TypeScript support
*
* @example
* // Basic usage
* const fastify = require('fastify')();
* const mcpSanitizerPlugin = require('mcp-sanitizer/middleware/fastify');
*
* fastify.register(mcpSanitizerPlugin, {
* policy: 'PRODUCTION',
* mode: 'sanitize',
* logWarnings: true
* });
*
* @example
* // Advanced configuration with custom handlers
* fastify.register(mcpSanitizerPlugin, {
* sanitizer: customSanitizerInstance,
* sanitizeBody: true,
* sanitizeParams: true,
* sanitizeQuery: true,
* onWarning: async (warnings, request, reply) => {
* request.log.warn('Sanitization warnings:', warnings);
* },
* onBlocked: async (warnings, request, reply) => {
* reply.code(400).send({ error: 'Request blocked', details: warnings });
* }
* });
*/
// Try to import fastify-plugin, fallback to manual plugin registration
let fp
try {
fp = require('fastify-plugin')
} catch (error) {
// Fallback function that mimics fastify-plugin behavior
fp = (fn, options = {}) => {
fn[Symbol.for('skip-override')] = true
fn[Symbol.for('plugin-meta')] = options
return fn
}
}
const MCPSanitizer = require('../index')
/**
* Default configuration for Fastify plugin
*/
const DEFAULT_CONFIG = {
// Sanitization options
sanitizeBody: true,
sanitizeParams: true,
sanitizeQuery: true,
sanitizeHeaders: false,
// Behavioral options
mode: 'sanitize', // 'sanitize' | 'block'
logWarnings: true,
addWarningsToRequest: true,
// Response options
blockStatusCode: 400,
errorMessage: 'Request blocked due to security concerns',
includeDetails: true,
// Performance options
skipHealthChecks: true,
skipStaticFiles: true,
usePreHandler: true, // Use preHandler hook vs onRequest
// Sanitizer configuration
policy: 'PRODUCTION',
sanitizer: null, // Will use default if not provided
// Callback functions
onWarning: null,
onBlocked: null,
onError: null,
// Fastify-specific options
schemaCompilation: true,
decorateRequest: true,
decorateFastify: false
}
/**
* Main plugin function
* @param {Object} fastify - Fastify instance
* @param {Object} options - Plugin options
* @param {Function} done - Plugin completion callback
*/
async function mcpSanitizerPlugin (fastify, options) {
const config = { ...DEFAULT_CONFIG, ...options }
// Initialize sanitizer if not provided
const sanitizer = config.sanitizer || new MCPSanitizer({
policy: config.policy,
...config.sanitizerOptions
})
// Decorate Fastify instance if requested
if (config.decorateFastify) {
fastify.decorate('mcpSanitizer', sanitizer)
fastify.decorate('mcpSanitizerConfig', config)
}
// Decorate request object if requested
if (config.decorateRequest) {
fastify.decorateRequest('sanitizationWarnings', null)
fastify.decorateRequest('sanitizationResults', null)
fastify.decorateRequest('mcpContext', null)
}
// Add schema compiler integration if enabled
if (config.schemaCompilation) {
addSchemaCompilerIntegration(fastify, sanitizer, config)
}
// Choose hook based on configuration
const hookName = config.usePreHandler ? 'preHandler' : 'onRequest'
fastify.addHook(hookName, async function sanitizationHook (request, reply) {
// Skip certain requests if configured
if (shouldSkipRequest(request, config)) {
return
}
try {
await processFastifyRequest(request, reply, sanitizer, config)
} catch (error) {
await handlePluginError(error, request, reply, config)
}
})
// Add response sanitization hook if needed
fastify.addHook('preSerialization', async function responsesanitizationHook (request, reply, payload) {
if (config.sanitizeResponse && payload) {
try {
const result = sanitizer.sanitize(payload, {
type: 'response_body',
path: request.url,
method: request.method
})
if (result.blocked) {
request.log.error('Response blocked by sanitizer:', result.warnings)
throw new Error('Response blocked due to security concerns')
}
if (result.warnings.length > 0) {
request.log.warn('Response sanitization warnings:', result.warnings)
}
return result.sanitized
} catch (error) {
request.log.error('Response sanitization error:', error)
throw error
}
}
return payload
})
// Register MCP-specific routes and handlers
await registerMCPRoutes(fastify, sanitizer, config)
}
/**
* Check if request should be skipped based on configuration
* @param {Object} request - Fastify request object
* @param {Object} config - Plugin configuration
* @returns {boolean} True if request should be skipped
*/
function shouldSkipRequest (request, config) {
// Skip health check endpoints
if (config.skipHealthChecks && isHealthCheckRequest(request)) {
return true
}
// Skip static file requests
if (config.skipStaticFiles && isStaticFileRequest(request)) {
return true
}
return false
}
/**
* Check if request is for health check endpoint
* @param {Object} request - Fastify request object
* @returns {boolean} True if health check request
*/
function isHealthCheckRequest (request) {
const healthPaths = ['/health', '/healthcheck', '/ping', '/status']
return healthPaths.some(path => request.url === path || request.url.startsWith(path + '/'))
}
/**
* Check if request is for static files
* @param {Object} request - Fastify request object
* @returns {boolean} True if static file request
*/
function isStaticFileRequest (request) {
const staticExtensions = ['.js', '.css', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.woff', '.woff2']
return staticExtensions.some(ext => request.url.endsWith(ext))
}
/**
* Process Fastify request for sanitization
* @param {Object} request - Fastify request object
* @param {Object} reply - Fastify reply object
* @param {MCPSanitizer} sanitizer - Sanitizer instance
* @param {Object} config - Plugin configuration
*/
async function processFastifyRequest (request, reply, sanitizer, config) {
const sanitizationResults = {}
let hasBlocked = false
const allWarnings = []
// Create sanitization tasks
const tasks = []
if (config.sanitizeBody && request.body) {
tasks.push({
type: 'body',
data: request.body,
context: { type: 'request_body', path: request.url, method: request.method }
})
}
if (config.sanitizeParams && request.params) {
tasks.push({
type: 'params',
data: request.params,
context: { type: 'request_params', path: request.url, method: request.method }
})
}
if (config.sanitizeQuery && request.query) {
tasks.push({
type: 'query',
data: request.query,
context: { type: 'request_query', path: request.url, method: request.method }
})
}
if (config.sanitizeHeaders && request.headers) {
tasks.push({
type: 'headers',
data: request.headers,
context: { type: 'request_headers', path: request.url, method: request.method }
})
}
// Process all sanitization tasks in parallel
const results = await Promise.all(
tasks.map(async (task) => {
const result = await Promise.resolve(sanitizer.sanitize(task.data, task.context))
return { type: task.type, result }
})
)
// Process results
for (const { type, result } of results) {
sanitizationResults[type] = result
if (result.blocked) hasBlocked = true
allWarnings.push(...result.warnings)
if (!result.blocked) {
request[type] = result.sanitized
}
}
// Handle blocking mode
if (config.mode === 'block' && hasBlocked) {
await handleBlockedRequest(request, reply, allWarnings, sanitizationResults, config)
return
}
// Handle warnings
if (allWarnings.length > 0) {
await handleWarnings(request, reply, allWarnings, sanitizationResults, config)
}
// Add sanitization data to request
if (config.addWarningsToRequest) {
request.sanitizationWarnings = allWarnings
request.sanitizationResults = sanitizationResults
}
}
/**
* Handle blocked requests
* @param {Object} request - Fastify request object
* @param {Object} reply - Fastify reply object
* @param {Array} warnings - Sanitization warnings
* @param {Object} results - Sanitization results
* @param {Object} config - Plugin configuration
*/
async function handleBlockedRequest (request, reply, warnings, results, config) {
// Log blocked request
if (config.logWarnings) {
request.log.warn('Blocked malicious request:', {
ip: request.ip,
userAgent: request.headers['user-agent'],
url: request.url,
method: request.method,
warnings: warnings.map(w => ({ type: w.type, message: w.message, severity: w.severity }))
})
}
// Call custom blocked handler if provided
if (config.onBlocked) {
const result = await config.onBlocked(warnings, request, reply, results)
if (result === false) return // Handler took care of response
}
// Send default blocked response
const response = {
error: config.errorMessage,
blocked: true,
timestamp: new Date().toISOString()
}
if (config.includeDetails) {
response.details = warnings.map(w => ({
type: w.type,
message: w.message,
severity: w.severity,
field: w.field
}))
}
reply.code(config.blockStatusCode).send(response)
}
/**
* Handle sanitization warnings
* @param {Object} request - Fastify request object
* @param {Object} reply - Fastify reply object
* @param {Array} warnings - Sanitization warnings
* @param {Object} results - Sanitization results
* @param {Object} config - Plugin configuration
*/
async function handleWarnings (request, reply, warnings, results, config) {
if (config.logWarnings) {
request.log.warn('Request sanitization warnings:', {
ip: request.ip,
userAgent: request.headers['user-agent'],
url: request.url,
method: request.method,
warnings: warnings.map(w => ({ type: w.type, message: w.message, severity: w.severity }))
})
}
// Call custom warning handler if provided
if (config.onWarning) {
await config.onWarning(warnings, request, reply, results)
}
}
/**
* Handle plugin errors
* @param {Error} error - Error object
* @param {Object} request - Fastify request object
* @param {Object} reply - Fastify reply object
* @param {Object} config - Plugin configuration
*/
async function handlePluginError (error, request, reply, config) {
request.log.error('MCP Sanitization plugin error:', {
error: error.message,
stack: error.stack,
url: request.url,
method: request.method,
ip: request.ip
})
// Call custom error handler if provided
if (config.onError) {
const result = await config.onError(error, request, reply)
if (result === false) return // Handler took care of response
}
// Send error response
reply.code(500).send({
error: 'Internal sanitization error',
timestamp: new Date().toISOString()
})
}
/**
* Add schema compiler integration for automatic validation
* @param {Object} fastify - Fastify instance
* @param {MCPSanitizer} sanitizer - Sanitizer instance
* @param {Object} config - Plugin configuration
*/
function addSchemaCompilerIntegration (fastify, sanitizer, config) {
// Store original schema compiler
const originalCompiler = fastify.schemaCompiler
// Replace with sanitization-aware compiler
fastify.setSchemaCompiler(function sanitizingSchemaCompiler (schema) {
// Get original compiled validator
const originalValidator = originalCompiler.call(this, schema)
// Return enhanced validator with sanitization
return function sanitizingValidator (data) {
// First sanitize the data
try {
const result = sanitizer.sanitize(data, { type: 'schema_validation' })
if (result.blocked && config.mode === 'block') {
return {
error: new Error('Data blocked by sanitizer'),
value: null
}
}
// Use sanitized data for validation
const sanitizedData = result.sanitized
const validationResult = originalValidator(sanitizedData)
// Add sanitization warnings to validation result
if (result.warnings.length > 0) {
if (validationResult.error) {
validationResult.error.sanitizationWarnings = result.warnings
} else {
validationResult.sanitizationWarnings = result.warnings
}
}
return validationResult
} catch (error) {
return {
error: new Error('Sanitization error during validation'),
value: null
}
}
}
})
}
/**
* Register MCP-specific routes and handlers
* @param {Object} fastify - Fastify instance
* @param {MCPSanitizer} sanitizer - Sanitizer instance
* @param {Object} config - Plugin configuration
*/
async function registerMCPRoutes (fastify, sanitizer, config) {
// Register MCP tool execution route with enhanced sanitization
fastify.register(async function mcpToolRoutes (fastify) {
fastify.addHook('preHandler', async function mcpToolPreHandler (request, reply) {
// Add MCP context
request.mcpContext = {
toolName: request.params.toolName || request.body?.tool_name,
isToolExecution: true,
timestamp: Date.now()
}
// Apply tool-specific sanitization
if (request.mcpContext.toolName && request.body?.parameters) {
try {
await applyToolSpecificSanitization(request, request.mcpContext.toolName, sanitizer, config)
} catch (error) {
if (error.code === 'MCP_TOOL_BLOCKED') {
reply.code(400).send({
error: 'Tool parameters blocked due to security concerns',
details: error.warnings,
toolName: request.mcpContext.toolName
})
return
}
throw error
}
}
})
// Tool execution endpoint
fastify.post('/tools/:toolName/execute', {
schema: {
params: {
type: 'object',
properties: {
toolName: { type: 'string' }
},
required: ['toolName']
},
body: {
type: 'object',
properties: {
parameters: { type: 'object' }
}
}
}
}, async function toolExecutionHandler (request, reply) {
const { toolName } = request.params
const { parameters } = request.body
// Execute tool with sanitized parameters
const result = {
success: true,
tool: toolName,
executed_at: new Date().toISOString(),
parameters,
warnings: request.sanitizationWarnings || []
}
return result
})
})
}
/**
* Apply tool-specific sanitization rules
* @param {Object} request - Fastify request object
* @param {string} toolName - Name of the MCP tool
* @param {MCPSanitizer} sanitizer - Sanitizer instance
* @param {Object} config - Plugin configuration
*/
async function applyToolSpecificSanitization (request, toolName, sanitizer, config) {
const params = request.body.parameters
if (!params) return
let hasBlocked = false
const toolWarnings = []
const sanitizationMap = {
file_reader: { field: 'file_path', type: 'file_path' },
file_writer: { field: 'file_path', type: 'file_path' },
web_scraper: { field: 'url', type: 'url' },
web_fetch: { field: 'url', type: 'url' },
shell_executor: { field: 'command', type: 'command' },
command_runner: { field: 'command', type: 'command' },
database_query: { field: 'query', type: 'sql' },
sql_executor: { field: 'query', type: 'sql' }
}
const sanitizationConfig = sanitizationMap[toolName]
if (sanitizationConfig && params[sanitizationConfig.field]) {
const result = await Promise.resolve(
sanitizer.sanitize(params[sanitizationConfig.field], { type: sanitizationConfig.type })
)
if (result.blocked) hasBlocked = true
toolWarnings.push(...result.warnings)
if (!result.blocked) {
params[sanitizationConfig.field] = result.sanitized
}
}
// Handle tool-specific blocking
if (config.mode === 'block' && hasBlocked) {
const error = new Error('Tool parameters blocked due to security concerns')
error.code = 'MCP_TOOL_BLOCKED'
error.warnings = toolWarnings
throw error
}
// Add tool warnings to request
if (toolWarnings.length > 0) {
request.sanitizationWarnings = request.sanitizationWarnings || []
request.sanitizationWarnings.push(...toolWarnings)
}
}
// Export as Fastify plugin
module.exports = fp(mcpSanitizerPlugin, {
fastify: '4.x',
name: 'mcp-sanitizer-plugin'
})
// Export configuration and utilities
module.exports.DEFAULT_CONFIG = DEFAULT_CONFIG
module.exports.mcpSanitizerPlugin = mcpSanitizerPlugin
/**
* Usage Examples:
*
* // Basic Fastify integration
* const fastify = require('fastify')({ logger: true });
* const mcpSanitizerPlugin = require('mcp-sanitizer/middleware/fastify');
*
* fastify.register(mcpSanitizerPlugin, {
* policy: 'PRODUCTION',
* mode: 'sanitize',
* logWarnings: true
* });
*
* // Advanced configuration with custom handlers
* fastify.register(mcpSanitizerPlugin, {
* sanitizer: new MCPSanitizer({ policy: 'STRICT' }),
* mode: 'block',
* async onWarning(warnings, request, reply) {
* request.log.warn('Sanitization warnings:', warnings);
* },
* async onBlocked(warnings, request, reply) {
* reply.code(400).send({
* error: 'Request blocked',
* details: warnings.map(w => w.message)
* });
* }
* });
*
* // Using decorated request properties
* fastify.get('/api/status', async (request, reply) => {
* return {
* warnings: request.sanitizationWarnings || [],
* results: request.sanitizationResults || {}
* };
* });
*
* // MCP tool execution with automatic sanitization
* fastify.post('/tools/:toolName/execute', async (request, reply) => {
* const { toolName } = request.params;
* const { parameters } = request.body;
*
* // Parameters are automatically sanitized by the plugin
* return {
* tool: toolName,
* parameters: parameters,
* warnings: request.sanitizationWarnings
* };
* });
*/