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(); constructor(private logger: ILogger, private routes: IRouteConfig[] = []) {} /** * 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; } } }