mcp-sanitizer
Version:
Comprehensive security sanitization library for Model Context Protocol (MCP) servers with trusted security libraries
335 lines (286 loc) • 10.2 kB
JavaScript
/**
* Common validation functions for MCP Sanitizer
*
* This module provides reusable validation functions that are
* used across different validators and sanitizers.
*/
const path = require('path')
const { URL } = require('url')
/**
* Validate that a value is a non-empty string
* @param {*} value - The value to validate
* @param {string} [paramName='value'] - Parameter name for error messages
* @throws {Error} - If value is not a non-empty string
*/
function validateNonEmptyString (value, paramName = 'value') {
if (typeof value !== 'string') {
throw new Error(`${paramName} must be a string`)
}
if (value.trim().length === 0) {
throw new Error(`${paramName} cannot be empty`)
}
}
/**
* Validate that a value is a positive number
* @param {*} value - The value to validate
* @param {string} [paramName='value'] - Parameter name for error messages
* @throws {Error} - If value is not a positive number
*/
function validatePositiveNumber (value, paramName = 'value') {
if (typeof value !== 'number') {
throw new Error(`${paramName} must be a number`)
}
if (!isFinite(value)) {
throw new Error(`${paramName} must be a finite number`)
}
if (value < 0) {
throw new Error(`${paramName} must be a positive number`)
}
}
/**
* Validate that a value is an array
* @param {*} value - The value to validate
* @param {string} [paramName='value'] - Parameter name for error messages
* @throws {Error} - If value is not an array
*/
function validateArray (value, paramName = 'value') {
if (!Array.isArray(value)) {
throw new Error(`${paramName} must be an array`)
}
}
/**
* Validate that a value is a function
* @param {*} value - The value to validate
* @param {string} [paramName='value'] - Parameter name for error messages
* @throws {Error} - If value is not a function
*/
function validateFunction (value, paramName = 'value') {
if (typeof value !== 'function') {
throw new Error(`${paramName} must be a function`)
}
}
/**
* Validate that a value is a RegExp
* @param {*} value - The value to validate
* @param {string} [paramName='value'] - Parameter name for error messages
* @throws {Error} - If value is not a RegExp
*/
function validateRegExp (value, paramName = 'value') {
if (!(value instanceof RegExp)) {
throw new Error(`${paramName} must be a RegExp`)
}
}
/**
* Validate file path for security issues
* @param {string} filePath - The file path to validate
* @returns {string} - Normalized file path
* @throws {Error} - If file path is unsafe
*/
function validateFilePath (filePath) {
validateNonEmptyString(filePath, 'filePath')
// Normalize the path for security checks
const normalizedPath = path.normalize(filePath)
// Check for directory traversal attempts
if (normalizedPath.includes('..')) {
throw new Error('Directory traversal detected in file path')
}
// Check for access to system directories (Unix/Linux)
const dangerousUnixPaths = ['/etc/', '/proc/', '/sys/', '/dev/', '/root/']
const dangerousWindowsPaths = ['C:\\Windows\\', 'C:\\System32\\', 'C:\\Program Files\\']
const lowerPath = normalizedPath.toLowerCase()
for (const dangerousPath of [...dangerousUnixPaths, ...dangerousWindowsPaths]) {
if (lowerPath.startsWith(dangerousPath.toLowerCase())) {
throw new Error(`Access to system directory not allowed: ${dangerousPath}`)
}
}
// Return original path if safe, not normalized
return filePath
}
/**
* Validate file extension against allowed list
* @param {string} filePath - The file path to validate
* @param {string[]} allowedExtensions - Array of allowed file extensions
* @throws {Error} - If file extension is not allowed
*/
function validateFileExtension (filePath, allowedExtensions) {
validateNonEmptyString(filePath, 'filePath')
validateArray(allowedExtensions, 'allowedExtensions')
const ext = path.extname(filePath).toLowerCase()
if (ext && !allowedExtensions.includes(ext)) {
throw new Error(`File extension ${ext} not allowed. Allowed extensions: ${allowedExtensions.join(', ')}`)
}
}
/**
* Validate URL for security issues
* @param {string} url - The URL to validate
* @param {string[]} [allowedProtocols=['http', 'https']] - Array of allowed protocols
* @returns {URL} - Parsed URL object
* @throws {Error} - If URL is unsafe
*/
function validateURL (url, allowedProtocols = ['http', 'https']) {
validateNonEmptyString(url, 'url')
validateArray(allowedProtocols, 'allowedProtocols')
let parsedUrl
try {
parsedUrl = new URL(url)
} catch (error) {
throw new Error('Invalid URL format')
}
// Check protocol
const protocol = parsedUrl.protocol.slice(0, -1) // Remove trailing colon
if (!allowedProtocols.includes(protocol)) {
throw new Error(`Protocol ${protocol} not allowed. Allowed protocols: ${allowedProtocols.join(', ')}`)
}
// Check for suspicious patterns in URL path
if (parsedUrl.pathname.includes('..')) {
throw new Error('Directory traversal detected in URL path')
}
return parsedUrl
}
/**
* Validate URL against restricted locations (localhost, private IPs, etc.)
* @param {string|URL} url - The URL to validate (string or URL object)
* @throws {Error} - If URL points to restricted location
*/
function validateURLLocation (url) {
let parsedUrl = url
if (typeof url === 'string') {
parsedUrl = new URL(url)
} else if (!(url instanceof URL)) {
throw new Error('URL must be a string or URL object')
}
const hostname = parsedUrl.hostname.toLowerCase()
// Check for localhost - allow localhost with explicit port for development
if ((hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') && !parsedUrl.port) {
throw new Error('URL points to localhost without explicit port')
}
// Check for private IP ranges
const privateIPPatterns = [
/^127\./, // 127.0.0.0/8 (loopback)
/^10\./, // 10.0.0.0/8 (private)
/^192\.168\./, // 192.168.0.0/16 (private)
/^172\.(1[6-9]|2[0-9]|3[01])\./ // 172.16.0.0/12 (private)
]
for (const pattern of privateIPPatterns) {
if (pattern.test(hostname)) {
throw new Error(`URL points to private IP range: ${hostname}`)
}
}
// Check for link-local addresses
if (hostname.startsWith('169.254.') || hostname.startsWith('fe80:')) {
throw new Error(`URL points to link-local address: ${hostname}`)
}
}
/**
* Validate command string for injection patterns
* @param {string} command - The command string to validate
* @returns {string} - Trimmed command string
* @throws {Error} - If command contains dangerous patterns
*/
function validateCommand (command) {
validateNonEmptyString(command, 'command')
// Check for command injection patterns - based on original logic
const dangerousPatterns = [
/[;&|`$(){}[\]]/, // Shell metacharacters
/(^|\s+)(rm|del|format|mkfs[\w.]*|dd)\s+/i, // Dangerous commands
/>\s*\/dev\/|<\s*\/dev\//, // Device redirection
/\|\s*nc\s+|\|\s*netcat\s+/i // Network tools
]
for (const pattern of dangerousPatterns) {
if (pattern.test(command)) {
throw new Error('Command contains dangerous patterns')
}
}
return command.trim()
}
/**
* Validate options object structure
* @param {object} options - Options object to validate
* @param {object} schema - Schema defining expected structure
* @throws {Error} - If options don't match schema
*/
function validateOptions (options, schema) {
if (typeof options !== 'object' || options === null) {
throw new Error('Options must be an object')
}
if (typeof schema !== 'object' || schema === null) {
throw new Error('Schema must be an object')
}
for (const [key, validator] of Object.entries(schema)) {
if (key in options) {
try {
validator(options[key], key)
} catch (error) {
throw new Error(`Invalid option '${key}': ${error.message}`)
}
}
}
}
/**
* Validate that a value is within a specified range
* @param {number} value - The value to validate
* @param {number} min - Minimum allowed value (inclusive)
* @param {number} max - Maximum allowed value (inclusive)
* @param {string} [paramName='value'] - Parameter name for error messages
* @throws {Error} - If value is outside the range
*/
function validateRange (value, min, max, paramName = 'value') {
validatePositiveNumber(value, paramName)
validatePositiveNumber(min, 'min')
validatePositiveNumber(max, 'max')
if (min > max) {
throw new Error('Minimum value cannot be greater than maximum value')
}
if (value < min || value > max) {
throw new Error(`${paramName} must be between ${min} and ${max} (inclusive)`)
}
}
/**
* Validate that an array contains only specific types
* @param {Array} array - The array to validate
* @param {string} expectedType - Expected type of array elements
* @param {string} [paramName='array'] - Parameter name for error messages
* @throws {Error} - If array contains elements of wrong type
*/
function validateArrayOfType (array, expectedType, paramName = 'array') {
validateArray(array, paramName)
for (let i = 0; i < array.length; i++) {
const element = array[i]
let actualType = typeof element
// Special handling for RegExp objects
if (expectedType === 'regexp' && element instanceof RegExp) {
actualType = 'regexp'
}
if (actualType !== expectedType) {
throw new Error(`${paramName}[${i}] must be of type ${expectedType}, got ${actualType}`)
}
}
}
/**
* Create a validator function that checks multiple conditions
* @param {...Function} validators - Validator functions to combine
* @returns {Function} - Combined validator function
*/
function combineValidators (...validators) {
return function (value, paramName) {
for (const validator of validators) {
validator(value, paramName)
}
}
}
module.exports = {
validateNonEmptyString,
validatePositiveNumber,
validateArray,
validateFunction,
validateRegExp,
validateFilePath,
validateFileExtension,
validateURL,
validateURLLocation,
validateCommand,
validateOptions,
validateRange,
validateArrayOfType,
combineValidators
}