fix(security): critical security and stability fixes
This commit is contained in:
@@ -22,6 +22,7 @@ import * as plugins from '../../../plugins.js';
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget, TPortRange, IRouteContext } from '../models/route-types.js';
|
||||
import { mergeRouteConfigs } from './route-utils.js';
|
||||
import { ProtocolDetector, HttpDetector } from '../../../detection/index.js';
|
||||
import { createSocketTracker } from '../../../core/utils/socket-tracker.js';
|
||||
|
||||
/**
|
||||
* Create an HTTP-only route configuration
|
||||
@@ -960,11 +961,12 @@ export const SocketHandlers = {
|
||||
* Now uses the centralized detection module for HTTP parsing
|
||||
*/
|
||||
httpRedirect: (locationTemplate: string, statusCode: number = 301) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||
const tracker = createSocketTracker(socket);
|
||||
const connectionId = ProtocolDetector.createConnectionId({
|
||||
socketId: context.connectionId || `${Date.now()}-${Math.random()}`
|
||||
});
|
||||
|
||||
socket.once('data', async (data) => {
|
||||
const handleData = async (data: Buffer) => {
|
||||
// Use detection module for parsing
|
||||
const detectionResult = await ProtocolDetector.detectWithConnectionTracking(
|
||||
data,
|
||||
@@ -1005,6 +1007,19 @@ export const SocketHandlers = {
|
||||
socket.end();
|
||||
// Clean up detection state
|
||||
ProtocolDetector.cleanupConnections();
|
||||
// Clean up all tracked resources
|
||||
tracker.cleanup();
|
||||
};
|
||||
|
||||
// Use tracker to manage the listener
|
||||
socket.once('data', handleData);
|
||||
|
||||
tracker.addListener('error', (err) => {
|
||||
tracker.safeDestroy(err);
|
||||
});
|
||||
|
||||
tracker.addListener('close', () => {
|
||||
tracker.cleanup();
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1013,7 +1028,9 @@ export const SocketHandlers = {
|
||||
* Now uses the centralized detection module for HTTP parsing
|
||||
*/
|
||||
httpServer: (handler: (req: { method: string; url: string; headers: Record<string, string>; body?: string }, res: { status: (code: number) => void; header: (name: string, value: string) => void; send: (data: string) => void; end: () => void }) => void) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||
const tracker = createSocketTracker(socket);
|
||||
let requestParsed = false;
|
||||
let responseTimer: NodeJS.Timeout | null = null;
|
||||
const connectionId = ProtocolDetector.createConnectionId({
|
||||
socketId: context.connectionId || `${Date.now()}-${Math.random()}`
|
||||
});
|
||||
@@ -1034,6 +1051,8 @@ export const SocketHandlers = {
|
||||
}
|
||||
|
||||
requestParsed = true;
|
||||
// Remove data listener after parsing request
|
||||
socket.removeListener('data', processData);
|
||||
const connInfo = detectionResult.connectionInfo;
|
||||
|
||||
// Create request object from detection result
|
||||
@@ -1060,6 +1079,12 @@ export const SocketHandlers = {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
|
||||
// Clear response timer since we're sending now
|
||||
if (responseTimer) {
|
||||
clearTimeout(responseTimer);
|
||||
responseTimer = null;
|
||||
}
|
||||
|
||||
if (!responseHeaders['content-type']) {
|
||||
responseHeaders['content-type'] = 'text/plain';
|
||||
}
|
||||
@@ -1091,30 +1116,44 @@ export const SocketHandlers = {
|
||||
try {
|
||||
handler(req, res);
|
||||
// Ensure response is sent even if handler doesn't call send()
|
||||
setTimeout(() => {
|
||||
responseTimer = setTimeout(() => {
|
||||
if (!ended) {
|
||||
res.send('');
|
||||
}
|
||||
responseTimer = null;
|
||||
}, 1000);
|
||||
// Track and unref the timer
|
||||
tracker.addTimer(responseTimer);
|
||||
} catch (error) {
|
||||
if (!ended) {
|
||||
res.status(500);
|
||||
res.send('Internal Server Error');
|
||||
}
|
||||
// Use safeDestroy for error cases
|
||||
tracker.safeDestroy(error instanceof Error ? error : new Error('Handler error'));
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('data', processData);
|
||||
// Use tracker to manage listeners
|
||||
tracker.addListener('data', processData);
|
||||
|
||||
socket.on('error', () => {
|
||||
tracker.addListener('error', (err) => {
|
||||
if (!requestParsed) {
|
||||
socket.end();
|
||||
tracker.safeDestroy(err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
tracker.addListener('close', () => {
|
||||
// Cleanup is handled by tracker
|
||||
// Clear any pending response timer
|
||||
if (responseTimer) {
|
||||
clearTimeout(responseTimer);
|
||||
responseTimer = null;
|
||||
}
|
||||
// Clean up detection state
|
||||
ProtocolDetector.cleanupConnections();
|
||||
// Clean up all tracked resources
|
||||
tracker.cleanup();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
453
ts/proxies/smart-proxy/utils/route-validator.ts
Normal file
453
ts/proxies/smart-proxy/utils/route-validator.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
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<string, string[]> } {
|
||||
const errorMap = new Map<string, string[]>();
|
||||
let valid = true;
|
||||
|
||||
// Check for duplicate route names
|
||||
const routeNames = new Set<string>();
|
||||
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<number, IRouteConfig[]>();
|
||||
|
||||
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<string, string[]>): 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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user