mcp-sanitizer
Version:
Comprehensive security sanitization library for Model Context Protocol (MCP) servers with trusted security libraries
892 lines (787 loc) • 26.6 kB
JavaScript
/**
* URL Validator for MCP Sanitizer
*
* This module provides comprehensive validation and sanitization for URLs,
* protecting against malicious URLs, SSRF attacks, and invalid protocols.
*
* Features:
* - Protocol validation and restriction
* - Hostname and IP validation
* - Private IP and localhost detection
* - URL structure validation
* - Query parameter sanitization
* - Port number validation
* - Configurable validation rules
* - Async validation support
*
* @example
* const { URLValidator } = require('./url');
* const validator = new URLValidator(config);
*
* const result = await validator.validate('https://example.com/path');
* if (result.isValid) {
* console.log('Sanitized URL:', result.sanitized);
* } else {
* console.error('Validation failed:', result.warnings);
* }
*/
const { URL } = require('url')
// const { validationUtils } = require('../../utils') // Unused - commented to fix ESLint
const { detectAllPatterns, SEVERITY_LEVELS } = require('../../patterns')
const validator = require('validator')
/**
* URL validation severity levels
*/
const SEVERITY = {
LOW: 'low',
MEDIUM: 'medium',
HIGH: 'high',
CRITICAL: 'critical'
}
/**
* Default configuration for URL validation
*/
const DEFAULT_CONFIG = {
allowedProtocols: ['https', 'http'],
blockedProtocols: ['file', 'ftp', 'javascript', 'data', 'vbscript'],
allowPrivateIPs: false,
allowLocalhost: false,
allowLoopback: false,
allowLinkLocal: false,
allowedPorts: [], // Empty array means all ports allowed
blockedPorts: [22, 23, 25, 53, 80, 135, 139, 445, 993, 995, 1433, 1521, 3306, 3389, 5432, 5984, 6379, 9200, 11211, 27017],
maxUrlLength: 2048,
maxQueryParams: 50,
maxQueryParamLength: 500,
allowCredentialsInUrl: false,
validateDNS: false, // Set to true for DNS resolution validation
strictMode: false,
customValidators: []
}
/**
* Private IP ranges and special addresses
*/
const PRIVATE_IP_RANGES = {
ipv4: [
/^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)
/^169\.254\./, // 169.254.0.0/16 (link-local)
/^224\./, // 224.0.0.0/4 (multicast)
/^255\.255\.255\.255$/ // broadcast
],
ipv6: [
/^::1$/, // loopback
/^fe80:/i, // link-local
/^fc00:/i, // unique local
/^fd00:/i, // unique local
/^ff00:/i // multicast
]
}
/**
* Dangerous hostname patterns
*/
const DANGEROUS_HOSTNAMES = [
/^localhost$/i,
/^127\.0\.0\.1$/,
/^::1$/,
/\.local$/i,
/\.internal$/i,
/\.corp$/i,
/\.lan$/i
]
/**
* URL Validator Class
*/
class URLValidator {
/**
* Create a new URL validator
* @param {Object} config - Validation configuration
*/
constructor (config = {}) {
this.config = { ...DEFAULT_CONFIG, ...config }
}
/**
* Validate a URL
* @param {string} url - The URL to validate
* @param {Object} options - Additional validation options
* @returns {Promise<Object>} Validation result
*/
async validate (url, options = {}) {
const result = {
isValid: false,
sanitized: null,
warnings: [],
severity: null,
metadata: {
originalUrl: url,
parsedUrl: null,
protocol: null,
hostname: null,
port: null,
isPrivateIP: false,
detectedPatterns: []
}
}
try {
// Basic input validation
if (typeof url !== 'string') {
result.warnings.push('URL must be a string')
result.severity = SEVERITY.HIGH
return result
}
if (!url || url.trim().length === 0) {
result.warnings.push('URL cannot be empty')
result.severity = SEVERITY.HIGH
return result
}
// Check URL length
if (url.length > this.config.maxUrlLength) {
result.warnings.push(`URL exceeds maximum length of ${this.config.maxUrlLength} characters`)
result.severity = SEVERITY.MEDIUM
return result
}
// Check for security patterns in raw URL
const patternResult = detectAllPatterns(url)
if (patternResult.detected) {
result.metadata.detectedPatterns = patternResult.patterns
result.warnings.push(`Security patterns detected: ${patternResult.patterns.join(', ')}`)
result.severity = this._mapSeverity(patternResult.severity)
if (patternResult.severity === SEVERITY_LEVELS.CRITICAL) {
return result
}
}
// First, use validator.js for additional URL validation (non-blocking)
const validatorOptions = {
protocols: this.config.allowedProtocols.map(p => p.replace(':', '')),
require_protocol: true,
require_valid_protocol: true,
disallow_auth: !this.config.allowCredentialsInUrl,
require_host: true,
require_port: false,
allow_protocol_relative_urls: false,
allow_fragments: true,
allow_query_components: true
}
// Only add as metadata, don't block validation to maintain backward compatibility
const validatorCheck = validator.isURL(url, validatorOptions)
result.metadata.validatorCheck = validatorCheck
if (!validatorCheck && this.config.useStrictValidation) {
result.warnings.push('URL failed validator.js validation')
result.severity = SEVERITY.MEDIUM
result.metadata.failedValidatorCheck = true
}
// Parse URL
let parsedUrl
try {
parsedUrl = new URL(url)
result.metadata.parsedUrl = {
protocol: parsedUrl.protocol,
hostname: parsedUrl.hostname,
port: parsedUrl.port,
pathname: parsedUrl.pathname,
search: parsedUrl.search,
hash: parsedUrl.hash
}
} catch (error) {
result.warnings.push(`Invalid URL format: ${error.message}`)
result.severity = SEVERITY.HIGH
return result
}
// Validate protocol
const protocolResult = this._validateProtocol(parsedUrl.protocol)
if (!protocolResult.isValid) {
result.warnings.push(...protocolResult.warnings)
result.severity = this._getHigherSeverity(result.severity, protocolResult.severity)
return result
}
result.metadata.protocol = parsedUrl.protocol
// Validate hostname and IP restrictions
const hostnameResult = await this._validateHostname(parsedUrl.hostname)
if (!hostnameResult.isValid) {
result.warnings.push(...hostnameResult.warnings)
result.severity = this._getHigherSeverity(result.severity, hostnameResult.severity)
if (hostnameResult.severity === SEVERITY.CRITICAL) {
return result
}
}
result.metadata.hostname = parsedUrl.hostname
result.metadata.isPrivateIP = hostnameResult.isPrivateIP
// Validate port
const portResult = this._validatePort(parsedUrl.port, parsedUrl.protocol)
if (!portResult.isValid) {
result.warnings.push(...portResult.warnings)
result.severity = this._getHigherSeverity(result.severity, portResult.severity)
if (portResult.severity === SEVERITY.CRITICAL) {
return result
}
}
result.metadata.port = parsedUrl.port || portResult.defaultPort
// Validate credentials in URL
if (!this.config.allowCredentialsInUrl && (parsedUrl.username || parsedUrl.password)) {
result.warnings.push('Credentials in URL are not allowed')
result.severity = this._getHigherSeverity(result.severity, SEVERITY.HIGH)
return result
}
// Validate query parameters
const queryResult = this._validateQueryParameters(parsedUrl.search)
if (!queryResult.isValid) {
result.warnings.push(...queryResult.warnings)
result.severity = this._getHigherSeverity(result.severity, queryResult.severity)
if (queryResult.severity === SEVERITY.CRITICAL) {
return result
}
}
// Validate path for dangerous patterns
const pathResult = this._validatePath(parsedUrl.pathname)
if (!pathResult.isValid) {
result.warnings.push(...pathResult.warnings)
result.severity = this._getHigherSeverity(result.severity, pathResult.severity)
}
// Run custom validators if configured
if (this.config.customValidators.length > 0) {
const customResult = await this._runCustomValidators(parsedUrl, options)
if (!customResult.isValid) {
result.warnings.push(...customResult.warnings)
result.severity = this._getHigherSeverity(result.severity, customResult.severity)
}
}
// If we get here, the URL is valid
result.isValid = true
result.sanitized = parsedUrl.toString()
// Set severity to lowest if there were warnings but URL is still valid
if (result.warnings.length === 0) {
result.severity = null
} else if (!result.severity) {
result.severity = SEVERITY.LOW
}
} catch (error) {
result.warnings.push(`Validation error: ${error.message}`)
result.severity = SEVERITY.HIGH
}
return result
}
/**
* Sanitize a URL
* @param {string} url - The URL to sanitize
* @param {Object} options - Sanitization options
* @returns {Promise<Object>} Sanitization result
*/
async sanitize (url, options = {}) {
const validationResult = await this.validate(url, options)
if (validationResult.isValid) {
return validationResult
}
// Attempt to sanitize the URL
let sanitized = url
const warnings = [...validationResult.warnings]
try {
// Remove credentials if not allowed
if (!this.config.allowCredentialsInUrl) {
sanitized = sanitized.replace(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^@/]+@)(.+)$/, '$1$3')
}
// Try to parse the sanitized URL
let parsedUrl
try {
parsedUrl = new URL(sanitized)
} catch (error) {
// If still invalid, try some basic fixes
if (!sanitized.includes('://')) {
sanitized = 'https://' + sanitized
}
parsedUrl = new URL(sanitized)
}
// Sanitize protocol if blocked
if (this.config.blockedProtocols.includes(parsedUrl.protocol.slice(0, -1))) {
parsedUrl.protocol = 'https:'
warnings.push('Changed blocked protocol to https')
}
// Remove dangerous query parameters
const searchParams = new URLSearchParams(parsedUrl.search)
let paramCount = 0
const sanitizedParams = new URLSearchParams()
for (const [key, value] of searchParams) {
if (paramCount >= this.config.maxQueryParams) {
warnings.push(`Removed excess query parameters (limit: ${this.config.maxQueryParams})`)
break
}
// Sanitize parameter value
let sanitizedValue = value
if (sanitizedValue.length > this.config.maxQueryParamLength) {
sanitizedValue = sanitizedValue.substring(0, this.config.maxQueryParamLength)
warnings.push(`Truncated query parameter '${key}' to maximum length`)
}
// Remove dangerous characters
sanitizedValue = sanitizedValue.replace(/[<>'"&]/g, '')
sanitizedParams.append(key, sanitizedValue)
paramCount++
}
parsedUrl.search = sanitizedParams.toString()
// Re-validate the sanitized URL
const revalidationResult = await this.validate(parsedUrl.toString(), options)
return {
isValid: revalidationResult.isValid,
sanitized: revalidationResult.isValid ? revalidationResult.sanitized : null,
warnings: [...warnings, ...revalidationResult.warnings],
severity: this._getHigherSeverity(validationResult.severity, revalidationResult.severity),
metadata: {
...validationResult.metadata,
wasSanitized: true,
sanitizationApplied: true
}
}
} catch (error) {
return {
isValid: false,
sanitized: null,
warnings: [...warnings, `Sanitization failed: ${error.message}`],
severity: SEVERITY.HIGH,
metadata: {
...validationResult.metadata,
wasSanitized: false,
sanitizationError: error.message
}
}
}
}
/**
* Validate URL protocol
* @param {string} protocol - Protocol to validate (includes colon)
* @returns {Object} Validation result
* @private
*/
_validateProtocol (protocol) {
const result = {
isValid: true,
warnings: [],
severity: null
}
const protocolName = protocol.slice(0, -1) // Remove trailing colon
// Check if protocol is blocked
if (this.config.blockedProtocols.includes(protocolName)) {
result.isValid = false
result.warnings.push(`Protocol '${protocolName}' is blocked for security reasons`)
result.severity = SEVERITY.CRITICAL
return result
}
// Check if protocol is allowed (handle both with and without colon)
const allowedList = this.config.allowedProtocols.map(p => p.replace(':', ''))
if (this.config.allowedProtocols.length > 0 && !allowedList.includes(protocolName)) {
result.isValid = false
result.warnings.push(`Protocol '${protocolName}' is not in allowed list: ${this.config.allowedProtocols.join(', ')}`)
result.severity = SEVERITY.HIGH
}
return result
}
/**
* Validate hostname and IP restrictions
* @param {string} hostname - Hostname to validate
* @returns {Promise<Object>} Validation result
* @private
*/
async _validateHostname (hostname) {
const result = {
isValid: true,
warnings: [],
severity: null,
isPrivateIP: false
}
if (!hostname) {
result.isValid = false
result.warnings.push('Hostname is missing')
result.severity = SEVERITY.HIGH
return result
}
// Check dangerous hostname patterns
for (const pattern of DANGEROUS_HOSTNAMES) {
if (pattern.test(hostname)) {
const allowanceCheck = this._checkHostnameAllowance(hostname)
if (!allowanceCheck.allowed) {
result.isValid = false
result.warnings.push(`Hostname '${hostname}' is ${allowanceCheck.reason}`)
result.severity = SEVERITY.CRITICAL
return result
} else {
result.warnings.push(`Warning: Accessing ${allowanceCheck.reason} hostname '${hostname}'`)
result.severity = SEVERITY.MEDIUM
}
}
}
// Check if hostname is an IP address
if (this._isIPAddress(hostname)) {
const ipResult = this._validateIPAddress(hostname)
result.isPrivateIP = ipResult.isPrivate
if (!ipResult.isValid) {
result.isValid = false
result.warnings.push(...ipResult.warnings)
result.severity = ipResult.severity
}
}
// DNS validation if enabled
if (this.config.validateDNS && result.isValid) {
try {
const dns = require('dns').promises
await dns.lookup(hostname)
} catch (error) {
result.warnings.push(`DNS resolution failed for hostname '${hostname}'`)
result.severity = this._getHigherSeverity(result.severity, SEVERITY.MEDIUM)
}
}
return result
}
/**
* Check if hostname access is allowed based on configuration
* @param {string} hostname - Hostname to check
* @returns {Object} Allowance check result
* @private
*/
_checkHostnameAllowance (hostname) {
const lower = hostname.toLowerCase()
if (lower === 'localhost' || lower === '127.0.0.1' || lower === '::1') {
return {
allowed: this.config.allowLocalhost || this.config.allowLoopback,
reason: 'localhost/loopback'
}
}
return { allowed: true, reason: null }
}
/**
* Check if a string is an IP address
* @param {string} hostname - Hostname to check
* @returns {boolean} True if it's an IP address
* @private
*/
_isIPAddress (hostname) {
// IPv4 pattern
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/
// IPv6 pattern (simplified)
const ipv6Pattern = /^([0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}$/i
return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname)
}
/**
* Validate IP address for private ranges and restrictions
* @param {string} ip - IP address to validate
* @returns {Object} Validation result
* @private
*/
_validateIPAddress (ip) {
const result = {
isValid: true,
warnings: [],
severity: null,
isPrivate: false
}
// Check IPv4 private ranges
for (const pattern of PRIVATE_IP_RANGES.ipv4) {
if (pattern.test(ip)) {
result.isPrivate = true
if (!this.config.allowPrivateIPs) {
result.isValid = false
result.warnings.push(`Access to private IP address '${ip}' is not allowed`)
result.severity = SEVERITY.CRITICAL
return result
} else {
result.warnings.push(`Warning: Accessing private IP address '${ip}'`)
result.severity = SEVERITY.MEDIUM
}
break
}
}
// Check IPv6 private ranges
for (const pattern of PRIVATE_IP_RANGES.ipv6) {
if (pattern.test(ip)) {
result.isPrivate = true
if (!this.config.allowPrivateIPs) {
result.isValid = false
result.warnings.push(`Access to private IPv6 address '${ip}' is not allowed`)
result.severity = SEVERITY.CRITICAL
return result
} else {
result.warnings.push(`Warning: Accessing private IPv6 address '${ip}'`)
result.severity = SEVERITY.MEDIUM
}
break
}
}
return result
}
/**
* Validate port number
* @param {string} port - Port number to validate
* @param {string} protocol - Protocol (with colon)
* @returns {Object} Validation result
* @private
*/
_validatePort (port, protocol) {
const result = {
isValid: true,
warnings: [],
severity: null,
defaultPort: null
}
// Get default port for protocol
const defaultPorts = {
'http:': 80,
'https:': 443,
'ftp:': 21,
'ssh:': 22
}
result.defaultPort = defaultPorts[protocol] || null
if (!port) {
return result // No explicit port is generally fine
}
const portNumber = parseInt(port, 10)
// Validate port number range
if (isNaN(portNumber) || portNumber < 1 || portNumber > 65535) {
result.isValid = false
result.warnings.push(`Invalid port number: ${port}`)
result.severity = SEVERITY.HIGH
return result
}
// Check blocked ports
if (this.config.blockedPorts.includes(portNumber)) {
result.isValid = false
result.warnings.push(`Port ${portNumber} is blocked for security reasons`)
result.severity = SEVERITY.CRITICAL
return result
}
// Check allowed ports if specified
if (this.config.allowedPorts.length > 0 && !this.config.allowedPorts.includes(portNumber)) {
result.isValid = false
result.warnings.push(`Port ${portNumber} is not in allowed list: ${this.config.allowedPorts.join(', ')}`)
result.severity = SEVERITY.HIGH
}
return result
}
/**
* Validate query parameters
* @param {string} search - Query string (including ?)
* @returns {Object} Validation result
* @private
*/
_validateQueryParameters (search) {
const result = {
isValid: true,
warnings: [],
severity: null
}
if (!search || search === '?') {
return result // No query parameters
}
try {
const params = new URLSearchParams(search)
let paramCount = 0
for (const [key, value] of params) {
paramCount++
// Check parameter count limit
if (paramCount > this.config.maxQueryParams) {
result.warnings.push(`Too many query parameters (limit: ${this.config.maxQueryParams})`)
result.severity = this._getHigherSeverity(result.severity, SEVERITY.MEDIUM)
break
}
// Check parameter value length
if (value.length > this.config.maxQueryParamLength) {
result.warnings.push(`Query parameter '${key}' exceeds maximum length (${this.config.maxQueryParamLength})`)
result.severity = this._getHigherSeverity(result.severity, SEVERITY.MEDIUM)
}
// Check for security patterns in parameter values
const patternResult = detectAllPatterns(value)
if (patternResult.detected) {
result.warnings.push(`Security patterns detected in query parameter '${key}': ${patternResult.patterns.join(', ')}`)
result.severity = this._getHigherSeverity(result.severity, this._mapSeverity(patternResult.severity))
}
}
} catch (error) {
result.warnings.push(`Invalid query parameters: ${error.message}`)
result.severity = SEVERITY.MEDIUM
}
return result
}
/**
* Validate URL path
* @param {string} pathname - URL path to validate
* @returns {Object} Validation result
* @private
*/
_validatePath (pathname) {
const result = {
isValid: true,
warnings: [],
severity: null
}
if (!pathname || pathname === '/') {
return result // Root path is fine
}
// Check for directory traversal
if (pathname.includes('..')) {
result.warnings.push('Directory traversal detected in URL path')
result.severity = SEVERITY.HIGH
}
// Check for null bytes
if (pathname.includes('\0')) {
result.isValid = false
result.warnings.push('Null byte detected in URL path')
result.severity = SEVERITY.CRITICAL
}
return result
}
/**
* Run custom validators
* @param {URL} parsedUrl - Parsed URL object
* @param {Object} options - Validation options
* @returns {Promise<Object>} Validation result
* @private
*/
async _runCustomValidators (parsedUrl, options) {
const result = {
isValid: true,
warnings: [],
severity: null
}
for (const validator of this.config.customValidators) {
try {
const customResult = await validator(parsedUrl, options, this.config)
if (!customResult.isValid) {
result.isValid = false
result.warnings.push(...(customResult.warnings || []))
result.severity = this._getHigherSeverity(result.severity, customResult.severity)
}
} catch (error) {
result.warnings.push(`Custom validator error: ${error.message}`)
result.severity = this._getHigherSeverity(result.severity, SEVERITY.MEDIUM)
}
}
return result
}
/**
* Map pattern detection severity to validator severity
* @param {string} patternSeverity - Pattern detection severity
* @returns {string} Validator severity
* @private
*/
_mapSeverity (patternSeverity) {
const mapping = {
[SEVERITY_LEVELS.LOW]: SEVERITY.LOW,
[SEVERITY_LEVELS.MEDIUM]: SEVERITY.MEDIUM,
[SEVERITY_LEVELS.HIGH]: SEVERITY.HIGH,
[SEVERITY_LEVELS.CRITICAL]: SEVERITY.CRITICAL
}
return mapping[patternSeverity] || SEVERITY.MEDIUM
}
/**
* Get the higher severity between two severity levels
* @param {string} current - Current severity
* @param {string} newSeverity - New severity to compare
* @returns {string} Higher severity
* @private
*/
_getHigherSeverity (current, newSeverity) {
if (!current) return newSeverity
if (!newSeverity) return current
const severityOrder = [SEVERITY.LOW, SEVERITY.MEDIUM, SEVERITY.HIGH, SEVERITY.CRITICAL]
const currentIndex = severityOrder.indexOf(current)
const newIndex = severityOrder.indexOf(newSeverity)
return newIndex > currentIndex ? newSeverity : current
}
/**
* Update validator configuration
* @param {Object} newConfig - New configuration to merge
*/
updateConfig (newConfig) {
this.config = { ...this.config, ...newConfig }
}
/**
* Get current configuration
* @returns {Object} Current configuration
*/
getConfig () {
return { ...this.config }
}
/**
* Check if a string is a valid URL using validator.js
* @param {string} url - URL to validate
* @param {Object} options - Validation options
* @returns {boolean} True if valid URL
*/
isURL (url, options = {}) {
const defaultOptions = {
protocols: this.config.allowedProtocols.map(p => p.replace(':', '')),
require_protocol: true,
require_valid_protocol: true,
disallow_auth: !this.config.allowCredentialsInUrl
}
return validator.isURL(url, { ...defaultOptions, ...options })
}
/**
* Check if URL is using HTTPS
* @param {string} url - URL to check
* @returns {boolean} True if HTTPS
*/
isHTTPS (url) {
try {
const parsed = new URL(url)
return parsed.protocol === 'https:'
} catch {
return false
}
}
/**
* Check if hostname is an IP address
* @param {string} hostname - Hostname to check
* @returns {boolean} True if IP address
*/
isIP (hostname) {
return validator.isIP(hostname)
}
/**
* Check if hostname is a fully qualified domain name
* @param {string} hostname - Hostname to check
* @returns {boolean} True if FQDN
*/
isFQDN (hostname) {
return validator.isFQDN(hostname, {
require_tld: true,
allow_underscores: false,
allow_trailing_dot: false
})
}
}
/**
* Create a URL validator with default configuration
* @param {Object} config - Optional configuration overrides
* @returns {URLValidator} New validator instance
*/
function createURLValidator (config = {}) {
return new URLValidator(config)
}
/**
* Quick validation function for simple use cases
* @param {string} url - URL to validate
* @param {Object} config - Optional configuration
* @returns {Promise<Object>} Validation result
*/
async function validateURL (url, config = {}) {
const validator = new URLValidator(config)
return await validator.validate(url)
}
/**
* Quick sanitization function for simple use cases
* @param {string} url - URL to sanitize
* @param {Object} config - Optional configuration
* @returns {Promise<Object>} Sanitization result
*/
async function sanitizeURL (url, config = {}) {
const validator = new URLValidator(config)
return await validator.sanitize(url)
}
module.exports = {
URLValidator,
createURLValidator,
validateURL,
sanitizeURL,
SEVERITY,
DEFAULT_CONFIG,
PRIVATE_IP_RANGES,
DANGEROUS_HOSTNAMES
}