import { logger } from '../../../core/utils/logger.js'; import type { IRouteConfig } from '../models/route-types.js'; /** * Validates route configurations for correctness and safety */ export class RouteValidator { private static readonly VALID_TLS_MODES = ['terminate', 'passthrough', 'terminate-and-reencrypt']; private static readonly VALID_ACTION_TYPES = ['forward', 'socket-handler']; private static readonly VALID_PROTOCOLS = ['tcp', 'http', 'https', 'ws', 'wss']; private static readonly MAX_PORTS = 100; private static readonly MAX_DOMAINS = 1000; private static readonly MAX_HEADER_SIZE = 8192; /** * Validate a single route configuration */ public static validateRoute(route: IRouteConfig): { valid: boolean; errors: string[] } { const errors: string[] = []; // Validate route has a name if (!route.name || typeof route.name !== 'string') { errors.push('Route must have a valid name'); } // Validate match criteria if (!route.match) { errors.push('Route must have match criteria'); } else { // Validate ports if (route.match.ports) { const ports = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports]; if (ports.length > this.MAX_PORTS) { errors.push(`Too many ports specified (max ${this.MAX_PORTS})`); } for (const port of ports) { if (typeof port === 'number') { if (!this.isValidPort(port)) { errors.push(`Invalid port: ${port}. Must be between 1 and 65535`); } } else if (typeof port === 'object' && 'from' in port && 'to' in port) { if (!this.isValidPort(port.from)) { errors.push(`Invalid port range start: ${port.from}. Must be between 1 and 65535`); } if (!this.isValidPort(port.to)) { errors.push(`Invalid port range end: ${port.to}. Must be between 1 and 65535`); } if (port.from > port.to) { errors.push(`Invalid port range: ${port.from}-${port.to} (start > end)`); } } else { errors.push(`Invalid port configuration: ${JSON.stringify(port)}`); } } } // Validate domains if (route.match.domains) { const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; if (domains.length > this.MAX_DOMAINS) { errors.push(`Too many domains specified (max ${this.MAX_DOMAINS})`); } for (const domain of domains) { if (!this.isValidDomain(domain)) { errors.push(`Invalid domain pattern: ${domain}`); } } } // Validate paths if (route.match.path) { const paths = Array.isArray(route.match.path) ? route.match.path : [route.match.path]; for (const path of paths) { if (!this.isValidPath(path)) { errors.push(`Invalid path pattern: ${path}`); } } } // Validate client IPs if (route.match.clientIp) { const ips = Array.isArray(route.match.clientIp) ? route.match.clientIp : [route.match.clientIp]; for (const ip of ips) { if (!this.isValidIPPattern(ip)) { errors.push(`Invalid IP pattern: ${ip}`); } } } // Validate headers if (route.match.headers) { for (const [key, value] of Object.entries(route.match.headers)) { if (key.length > 256) { errors.push(`Header name too long: ${key}`); } const headerValue = String(value); if (headerValue.length > this.MAX_HEADER_SIZE) { errors.push(`Header value too long for ${key} (max ${this.MAX_HEADER_SIZE} bytes)`); } if (!/^[\x20-\x7E]+$/.test(key)) { errors.push(`Invalid header name: ${key} (must be printable ASCII)`); } } } // Protocol validation removed - not part of IRouteMatch interface } // Validate action if (!route.action) { errors.push('Route must have an action'); } else { // Validate action type if (!route.action.type || !this.VALID_ACTION_TYPES.includes(route.action.type)) { errors.push(`Invalid action type: ${route.action.type}. Must be one of: ${this.VALID_ACTION_TYPES.join(', ')}`); } // Validate socket-handler if (route.action.type === 'socket-handler') { if (typeof route.action.socketHandler !== 'function') { errors.push('socket-handler action requires a socketHandler function'); } } // Validate forward target if (route.action.type === 'forward') { if (!route.action.targets || route.action.targets.length === 0) { errors.push('Forward action must have at least one target'); } else { for (const target of route.action.targets) { if (!target.host) { errors.push('Target must have a host'); } else if (typeof target.host !== 'string' && !Array.isArray(target.host) && typeof target.host !== 'function') { errors.push('Target host must be a string, array of strings, or function'); } if (target.port) { if (typeof target.port === 'number' && !this.isValidPort(target.port)) { errors.push(`Invalid target port: ${target.port}`); } else if (target.port !== 'preserve' && typeof target.port !== 'function' && typeof target.port !== 'number') { errors.push(`Invalid target port configuration: ${target.port}`); } } } } } // Validate TLS settings if (route.action.tls) { if (route.action.tls.mode && !this.VALID_TLS_MODES.includes(route.action.tls.mode)) { errors.push(`Invalid TLS mode: ${route.action.tls.mode}. Must be one of: ${this.VALID_TLS_MODES.join(', ')}`); } if (route.action.tls.certificate) { if (route.action.tls.certificate !== 'auto' && typeof route.action.tls.certificate !== 'object') { errors.push('TLS certificate must be "auto" or a certificate configuration object'); } } if (route.action.tls.versions) { for (const version of route.action.tls.versions) { if (!['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3'].includes(version)) { errors.push(`Invalid TLS version: ${version}`); } } } } } // Validate security settings if (route.security) { // Validate IP allow/block lists if (route.security.ipAllowList) { const allowList = Array.isArray(route.security.ipAllowList) ? route.security.ipAllowList : [route.security.ipAllowList]; for (const ip of allowList) { if (!this.isValidIPPattern(ip)) { errors.push(`Invalid IP pattern in allow list: ${ip}`); } } } if (route.security.ipBlockList) { const blockList = Array.isArray(route.security.ipBlockList) ? route.security.ipBlockList : [route.security.ipBlockList]; for (const ip of blockList) { if (!this.isValidIPPattern(ip)) { errors.push(`Invalid IP pattern in block list: ${ip}`); } } } // Validate rate limits if (route.security.rateLimit) { if (route.security.rateLimit.maxRequests && route.security.rateLimit.maxRequests < 0) { errors.push('Rate limit maxRequests must be positive'); } if (route.security.rateLimit.window && route.security.rateLimit.window < 0) { errors.push('Rate limit window must be positive'); } } // Validate connection limits if (route.security.maxConnections && route.security.maxConnections < 0) { errors.push('Max connections must be positive'); } } // Validate priority if (route.priority !== undefined && (route.priority < 0 || route.priority > 10000)) { errors.push('Priority must be between 0 and 10000'); } return { valid: errors.length === 0, errors }; } /** * Validate multiple route configurations */ public static validateRoutes(routes: IRouteConfig[]): { valid: boolean; errors: Map } { const errorMap = new Map(); let valid = true; // Check for duplicate route names const routeNames = new Set(); for (const route of routes) { if (route.name && routeNames.has(route.name)) { const existingErrors = errorMap.get(route.name) || []; existingErrors.push('Duplicate route name'); errorMap.set(route.name, existingErrors); valid = false; } routeNames.add(route.name); } // Validate each route for (const route of routes) { const result = this.validateRoute(route); if (!result.valid) { errorMap.set(route.name || 'unnamed', result.errors); valid = false; } } // Check for conflicting routes const conflicts = this.findRouteConflicts(routes); if (conflicts.length > 0) { for (const conflict of conflicts) { const existingErrors = errorMap.get(conflict.route) || []; existingErrors.push(conflict.message); errorMap.set(conflict.route, existingErrors); } valid = false; } return { valid, errors: errorMap }; } /** * Find potential conflicts between routes */ private static findRouteConflicts(routes: IRouteConfig[]): Array<{ route: string; message: string }> { const conflicts: Array<{ route: string; message: string }> = []; // Group routes by port const portMap = new Map(); for (const route of routes) { if (route.match?.ports) { const ports = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports]; // Expand port ranges to individual ports const expandedPorts: number[] = []; for (const port of ports) { if (typeof port === 'number') { expandedPorts.push(port); } else if (typeof port === 'object' && 'from' in port && 'to' in port) { for (let p = port.from; p <= port.to; p++) { expandedPorts.push(p); } } } for (const port of expandedPorts) { const routesOnPort = portMap.get(port) || []; routesOnPort.push(route); portMap.set(port, routesOnPort); } } } // Check for conflicting catch-all routes on the same port for (const [port, routesOnPort] of portMap) { const catchAllRoutes = routesOnPort.filter(r => !r.match.domains || (Array.isArray(r.match.domains) && r.match.domains.includes('*')) || r.match.domains === '*' ); if (catchAllRoutes.length > 1) { for (const route of catchAllRoutes) { conflicts.push({ route: route.name, message: `Multiple catch-all routes on port ${port}` }); } } } return conflicts; } /** * Validate port number */ private static isValidPort(port: number): boolean { return Number.isInteger(port) && port >= 1 && port <= 65535; } /** * Validate domain pattern */ private static isValidDomain(domain: string): boolean { if (!domain || typeof domain !== 'string') return false; if (domain === '*') return true; // Basic domain pattern validation const domainPattern = /^(\*\.)?([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; return domainPattern.test(domain) || domain === 'localhost'; } /** * Validate path pattern */ private static isValidPath(path: string): boolean { if (!path || typeof path !== 'string') return false; if (!path.startsWith('/')) return false; // Check for invalid characters if (!/^[a-zA-Z0-9/_*:{}.-]+$/.test(path)) return false; // Validate parameter syntax const paramPattern = /\{[a-zA-Z_][a-zA-Z0-9_]*\}/g; const params = path.match(paramPattern) || []; for (const param of params) { if (param.length > 32) return false; } return true; } /** * Validate IP pattern */ private static isValidIPPattern(ip: string): boolean { if (!ip || typeof ip !== 'string') return false; if (ip === '*') return true; // Check for CIDR notation if (ip.includes('/')) { const [addr, prefix] = ip.split('/'); const prefixNum = parseInt(prefix, 10); if (addr.includes(':')) { // IPv6 CIDR return this.isValidIPv6(addr) && prefixNum >= 0 && prefixNum <= 128; } else { // IPv4 CIDR return this.isValidIPv4(addr) && prefixNum >= 0 && prefixNum <= 32; } } // Check for range if (ip.includes('-')) { const [start, end] = ip.split('-'); return (this.isValidIPv4(start) && this.isValidIPv4(end)) || (this.isValidIPv6(start) && this.isValidIPv6(end)); } // Check for wildcards in IPv4 if (ip.includes('*') && !ip.includes(':')) { const parts = ip.split('.'); if (parts.length !== 4) return false; for (const part of parts) { if (part !== '*' && !/^\d{1,3}$/.test(part)) return false; if (part !== '*' && parseInt(part, 10) > 255) return false; } return true; } // Regular IP address return this.isValidIPv4(ip) || this.isValidIPv6(ip); } /** * Validate IPv4 address */ private static isValidIPv4(ip: string): boolean { const parts = ip.split('.'); if (parts.length !== 4) return false; for (const part of parts) { const num = parseInt(part, 10); if (isNaN(num) || num < 0 || num > 255) return false; } return true; } /** * Validate IPv6 address */ private static isValidIPv6(ip: string): boolean { // Simple IPv6 validation const ipv6Pattern = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{1,4}){0,6}|::1|::)$/; return ipv6Pattern.test(ip); } /** * Log validation errors */ public static logValidationErrors(errors: Map): void { for (const [routeName, routeErrors] of errors) { logger.log('error', `Route validation failed for ${routeName}:`, { route: routeName, errors: routeErrors, component: 'route-validator' }); for (const error of routeErrors) { logger.log('error', ` - ${error}`, { route: routeName, component: 'route-validator' }); } } } }