import * as plugins from '../../plugins.js'; import type { ILogger } from './models/types.js'; import type { IRouteConfig } from '../smart-proxy/models/route-types.js'; import type { IRouteContext } from '../../core/models/route-context.js'; /** * Manages security features for the NetworkProxy * Implements Phase 5.4: Security features like IP filtering and rate limiting */ export class SecurityManager { // Cache IP filtering results to avoid constant regex matching private ipFilterCache: Map> = new Map(); // Store rate limits per route and key private rateLimits: Map> = new Map(); // Connection tracking by IP private connectionsByIP: Map> = new Map(); private connectionRateByIP: Map = new Map(); constructor(private logger: ILogger, private routes: IRouteConfig[] = [], private maxConnectionsPerIP: number = 100, private connectionRateLimitPerMinute: number = 300) { // Start periodic cleanup for connection tracking this.startPeriodicIpCleanup(); } /** * Update the routes configuration */ public setRoutes(routes: IRouteConfig[]): void { this.routes = routes; // Reset caches when routes change this.ipFilterCache.clear(); } /** * Check if a client is allowed to access a specific route * * @param route The route to check access for * @param context The route context with client information * @returns True if access is allowed, false otherwise */ public isAllowed(route: IRouteConfig, context: IRouteContext): boolean { if (!route.security) { return true; // No security restrictions } // --- IP filtering --- if (!this.isIpAllowed(route, context.clientIp)) { this.logger.debug(`IP ${context.clientIp} is blocked for route ${route.name || route.id || 'unnamed'}`); return false; } // --- Rate limiting --- if (route.security.rateLimit?.enabled && !this.isWithinRateLimit(route, context)) { this.logger.debug(`Rate limit exceeded for route ${route.name || route.id || 'unnamed'}`); return false; } // --- Basic Auth (handled at HTTP level) --- // Basic auth is not checked here as it requires HTTP headers // and is handled in the RequestHandler return true; } /** * Check if an IP is allowed based on route security settings */ private isIpAllowed(route: IRouteConfig, clientIp: string): boolean { if (!route.security) { return true; // No security restrictions } const routeId = route.id || route.name || 'unnamed'; // Check cache first if (!this.ipFilterCache.has(routeId)) { this.ipFilterCache.set(routeId, new Map()); } const routeCache = this.ipFilterCache.get(routeId)!; if (routeCache.has(clientIp)) { return routeCache.get(clientIp)!; } let allowed = true; // Check block list first (deny has priority over allow) if (route.security.ipBlockList && route.security.ipBlockList.length > 0) { if (this.ipMatchesPattern(clientIp, route.security.ipBlockList)) { allowed = false; } } // Then check allow list (overrides block list if specified) if (route.security.ipAllowList && route.security.ipAllowList.length > 0) { // If allow list is specified, IP must match an entry to be allowed allowed = this.ipMatchesPattern(clientIp, route.security.ipAllowList); } // Cache the result routeCache.set(clientIp, allowed); return allowed; } /** * Check if IP matches any pattern in the list */ private ipMatchesPattern(ip: string, patterns: string[]): boolean { for (const pattern of patterns) { // CIDR notation if (pattern.includes('/')) { if (this.ipMatchesCidr(ip, pattern)) { return true; } } // Wildcard notation else if (pattern.includes('*')) { const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$'); if (regex.test(ip)) { return true; } } // Exact match else if (pattern === ip) { return true; } } return false; } /** * Check if IP matches CIDR notation * Very basic implementation - for production use, consider a dedicated IP library */ private ipMatchesCidr(ip: string, cidr: string): boolean { try { const [subnet, bits] = cidr.split('/'); const mask = parseInt(bits, 10); // Convert IP to numeric format const ipParts = ip.split('.').map(part => parseInt(part, 10)); const subnetParts = subnet.split('.').map(part => parseInt(part, 10)); // Calculate the numeric IP and subnet const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3]; const subnetNum = (subnetParts[0] << 24) | (subnetParts[1] << 16) | (subnetParts[2] << 8) | subnetParts[3]; // Calculate the mask const maskNum = ~((1 << (32 - mask)) - 1); // Check if IP is in subnet return (ipNum & maskNum) === (subnetNum & maskNum); } catch (e) { this.logger.error(`Invalid CIDR notation: ${cidr}`); return false; } } /** * Check if request is within rate limit */ private isWithinRateLimit(route: IRouteConfig, context: IRouteContext): boolean { if (!route.security?.rateLimit?.enabled) { return true; } const rateLimit = route.security.rateLimit; const routeId = route.id || route.name || 'unnamed'; // Determine rate limit key (by IP, path, or header) let key = context.clientIp; // Default to IP if (rateLimit.keyBy === 'path' && context.path) { key = `${context.clientIp}:${context.path}`; } else if (rateLimit.keyBy === 'header' && rateLimit.headerName && context.headers) { const headerValue = context.headers[rateLimit.headerName.toLowerCase()]; if (headerValue) { key = `${context.clientIp}:${headerValue}`; } } // Get or create rate limit tracking for this route if (!this.rateLimits.has(routeId)) { this.rateLimits.set(routeId, new Map()); } const routeLimits = this.rateLimits.get(routeId)!; const now = Date.now(); // Get or create rate limit tracking for this key let limit = routeLimits.get(key); if (!limit || limit.expiry < now) { // Create new rate limit or reset expired one limit = { count: 1, expiry: now + (rateLimit.window * 1000) }; routeLimits.set(key, limit); return true; } // Increment the counter limit.count++; // Check if rate limit is exceeded return limit.count <= rateLimit.maxRequests; } /** * Clean up expired rate limits * Should be called periodically to prevent memory leaks */ public cleanupExpiredRateLimits(): void { const now = Date.now(); for (const [routeId, routeLimits] of this.rateLimits.entries()) { let removed = 0; for (const [key, limit] of routeLimits.entries()) { if (limit.expiry < now) { routeLimits.delete(key); removed++; } } if (removed > 0) { this.logger.debug(`Cleaned up ${removed} expired rate limits for route ${routeId}`); } } } /** * Check basic auth credentials * * @param route The route to check auth for * @param username The provided username * @param password The provided password * @returns True if credentials are valid, false otherwise */ public checkBasicAuth(route: IRouteConfig, username: string, password: string): boolean { if (!route.security?.basicAuth?.enabled) { return true; } const basicAuth = route.security.basicAuth; // Check credentials against configured users for (const user of basicAuth.users) { if (user.username === username && user.password === password) { return true; } } return false; } /** * Verify a JWT token * * @param route The route to verify the token for * @param token The JWT token to verify * @returns True if the token is valid, false otherwise */ public verifyJwtToken(route: IRouteConfig, token: string): boolean { if (!route.security?.jwtAuth?.enabled) { return true; } try { // This is a simplified version - in production you'd use a proper JWT library const jwtAuth = route.security.jwtAuth; // Verify structure const parts = token.split('.'); if (parts.length !== 3) { return false; } // Decode payload const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); // Check expiration if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { return false; } // Check issuer if (jwtAuth.issuer && payload.iss !== jwtAuth.issuer) { return false; } // Check audience if (jwtAuth.audience && payload.aud !== jwtAuth.audience) { return false; } // In a real implementation, you'd also verify the signature // using the secret and algorithm specified in jwtAuth return true; } catch (err) { this.logger.error(`Error verifying JWT: ${err}`); return false; } } /** * Get connections count by IP */ public getConnectionCountByIP(ip: string): number { return this.connectionsByIP.get(ip)?.size || 0; } /** * Check and update connection rate for an IP * @returns true if within rate limit, false if exceeding limit */ public checkConnectionRate(ip: string): boolean { const now = Date.now(); const minute = 60 * 1000; if (!this.connectionRateByIP.has(ip)) { this.connectionRateByIP.set(ip, [now]); return true; } // Get timestamps and filter out entries older than 1 minute const timestamps = this.connectionRateByIP.get(ip)!.filter((time) => now - time < minute); timestamps.push(now); this.connectionRateByIP.set(ip, timestamps); // Check if rate exceeds limit return timestamps.length <= this.connectionRateLimitPerMinute; } /** * Track connection by IP */ public trackConnectionByIP(ip: string, connectionId: string): void { if (!this.connectionsByIP.has(ip)) { this.connectionsByIP.set(ip, new Set()); } this.connectionsByIP.get(ip)!.add(connectionId); } /** * Remove connection tracking for an IP */ public removeConnectionByIP(ip: string, connectionId: string): void { if (this.connectionsByIP.has(ip)) { const connections = this.connectionsByIP.get(ip)!; connections.delete(connectionId); if (connections.size === 0) { this.connectionsByIP.delete(ip); } } } /** * Check if IP should be allowed considering connection rate and max connections * @returns Object with result and reason */ public validateIP(ip: string): { allowed: boolean; reason?: string } { // Check connection count limit if (this.getConnectionCountByIP(ip) >= this.maxConnectionsPerIP) { return { allowed: false, reason: `Maximum connections per IP (${this.maxConnectionsPerIP}) exceeded` }; } // Check connection rate limit if (!this.checkConnectionRate(ip)) { return { allowed: false, reason: `Connection rate limit (${this.connectionRateLimitPerMinute}/min) exceeded` }; } return { allowed: true }; } /** * Clears all IP tracking data (for shutdown) */ public clearIPTracking(): void { this.connectionsByIP.clear(); this.connectionRateByIP.clear(); } /** * Start periodic cleanup of IP tracking data */ private startPeriodicIpCleanup(): void { // Clean up IP tracking data every minute setInterval(() => { this.performIpCleanup(); }, 60000).unref(); } /** * Perform cleanup of expired IP data */ private performIpCleanup(): void { const now = Date.now(); const minute = 60 * 1000; let cleanedRateLimits = 0; let cleanedIPs = 0; // Clean up expired rate limit timestamps for (const [ip, timestamps] of this.connectionRateByIP.entries()) { const validTimestamps = timestamps.filter(time => now - time < minute); if (validTimestamps.length === 0) { this.connectionRateByIP.delete(ip); cleanedRateLimits++; } else if (validTimestamps.length < timestamps.length) { this.connectionRateByIP.set(ip, validTimestamps); } } // Clean up IPs with no active connections for (const [ip, connections] of this.connectionsByIP.entries()) { if (connections.size === 0) { this.connectionsByIP.delete(ip); cleanedIPs++; } } if (cleanedRateLimits > 0 || cleanedIPs > 0) { this.logger.debug(`IP cleanup: removed ${cleanedIPs} IPs and ${cleanedRateLimits} rate limits`); } } }