333 lines
9.3 KiB
TypeScript
333 lines
9.3 KiB
TypeScript
import * as plugins from '../../plugins.js';
|
|
import type { IRouteConfig, IRouteContext } from '../../proxies/smart-proxy/models/route-types.js';
|
|
import type {
|
|
IIpValidationResult,
|
|
IIpConnectionInfo,
|
|
ISecurityLogger,
|
|
IRateLimitInfo
|
|
} from './security-utils.js';
|
|
import {
|
|
isIPAuthorized,
|
|
checkMaxConnections,
|
|
checkConnectionRate,
|
|
trackConnection,
|
|
removeConnection,
|
|
cleanupExpiredRateLimits,
|
|
parseBasicAuthHeader
|
|
} from './security-utils.js';
|
|
|
|
/**
|
|
* Shared SecurityManager for use across proxy components
|
|
* Handles IP tracking, rate limiting, and authentication
|
|
*/
|
|
export class SharedSecurityManager {
|
|
// IP connection tracking
|
|
private connectionsByIP: Map<string, IIpConnectionInfo> = new Map();
|
|
|
|
// Route-specific rate limiting
|
|
private rateLimits: Map<string, Map<string, IRateLimitInfo>> = new Map();
|
|
|
|
// Cache IP filtering results to avoid constant regex matching
|
|
private ipFilterCache: Map<string, Map<string, boolean>> = new Map();
|
|
|
|
// Default limits
|
|
private maxConnectionsPerIP: number;
|
|
private connectionRateLimitPerMinute: number;
|
|
|
|
// Cache cleanup interval
|
|
private cleanupInterval: NodeJS.Timeout | null = null;
|
|
|
|
/**
|
|
* Create a new SharedSecurityManager
|
|
*
|
|
* @param options - Configuration options
|
|
* @param logger - Logger instance
|
|
*/
|
|
constructor(options: {
|
|
maxConnectionsPerIP?: number;
|
|
connectionRateLimitPerMinute?: number;
|
|
cleanupIntervalMs?: number;
|
|
routes?: IRouteConfig[];
|
|
}, private logger?: ISecurityLogger) {
|
|
this.maxConnectionsPerIP = options.maxConnectionsPerIP || 100;
|
|
this.connectionRateLimitPerMinute = options.connectionRateLimitPerMinute || 300;
|
|
|
|
// Set up logger with defaults if not provided
|
|
this.logger = logger || {
|
|
info: console.log,
|
|
warn: console.warn,
|
|
error: console.error
|
|
};
|
|
|
|
// Set up cache cleanup interval
|
|
const cleanupInterval = options.cleanupIntervalMs || 60000; // Default: 1 minute
|
|
this.cleanupInterval = setInterval(() => {
|
|
this.cleanupCaches();
|
|
}, cleanupInterval);
|
|
|
|
// Don't keep the process alive just for cleanup
|
|
if (this.cleanupInterval.unref) {
|
|
this.cleanupInterval.unref();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get connections count by IP
|
|
*
|
|
* @param ip - The IP address to check
|
|
* @returns Number of connections from this IP
|
|
*/
|
|
public getConnectionCountByIP(ip: string): number {
|
|
return this.connectionsByIP.get(ip)?.connections.size || 0;
|
|
}
|
|
|
|
/**
|
|
* Track connection by IP
|
|
*
|
|
* @param ip - The IP address to track
|
|
* @param connectionId - The connection ID to associate
|
|
*/
|
|
public trackConnectionByIP(ip: string, connectionId: string): void {
|
|
trackConnection(ip, connectionId, this.connectionsByIP);
|
|
}
|
|
|
|
/**
|
|
* Remove connection tracking for an IP
|
|
*
|
|
* @param ip - The IP address to update
|
|
* @param connectionId - The connection ID to remove
|
|
*/
|
|
public removeConnectionByIP(ip: string, connectionId: string): void {
|
|
removeConnection(ip, connectionId, this.connectionsByIP);
|
|
}
|
|
|
|
/**
|
|
* Check if IP is authorized based on route security settings
|
|
*
|
|
* @param ip - The IP address to check
|
|
* @param allowedIPs - List of allowed IP patterns
|
|
* @param blockedIPs - List of blocked IP patterns
|
|
* @returns Whether the IP is authorized
|
|
*/
|
|
public isIPAuthorized(
|
|
ip: string,
|
|
allowedIPs: string[] = ['*'],
|
|
blockedIPs: string[] = []
|
|
): boolean {
|
|
return isIPAuthorized(ip, allowedIPs, blockedIPs);
|
|
}
|
|
|
|
/**
|
|
* Validate IP against rate limits and connection limits
|
|
*
|
|
* @param ip - The IP address to validate
|
|
* @returns Result with allowed status and reason if blocked
|
|
*/
|
|
public validateIP(ip: string): IIpValidationResult {
|
|
// Check connection count limit
|
|
const connectionResult = checkMaxConnections(
|
|
ip,
|
|
this.connectionsByIP,
|
|
this.maxConnectionsPerIP
|
|
);
|
|
if (!connectionResult.allowed) {
|
|
return connectionResult;
|
|
}
|
|
|
|
// Check connection rate limit
|
|
const rateResult = checkConnectionRate(
|
|
ip,
|
|
this.connectionsByIP,
|
|
this.connectionRateLimitPerMinute
|
|
);
|
|
if (!rateResult.allowed) {
|
|
return rateResult;
|
|
}
|
|
|
|
return { allowed: true };
|
|
}
|
|
|
|
/**
|
|
* Check if a client is allowed to access a specific route
|
|
*
|
|
* @param route - The route to check
|
|
* @param context - The request context
|
|
* @returns Whether access is allowed
|
|
*/
|
|
public isAllowed(route: IRouteConfig, context: IRouteContext): boolean {
|
|
if (!route.security) {
|
|
return true; // No security restrictions
|
|
}
|
|
|
|
// --- IP filtering ---
|
|
if (!this.isClientIpAllowed(route, context.clientIp)) {
|
|
this.logger?.debug?.(`IP ${context.clientIp} is blocked for route ${route.name || 'unnamed'}`);
|
|
return false;
|
|
}
|
|
|
|
// --- Rate limiting ---
|
|
if (route.security.rateLimit?.enabled && !this.isWithinRateLimit(route, context)) {
|
|
this.logger?.debug?.(`Rate limit exceeded for route ${route.name || 'unnamed'}`);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check if a client IP is allowed for a route
|
|
*
|
|
* @param route - The route to check
|
|
* @param clientIp - The client IP
|
|
* @returns Whether the IP is allowed
|
|
*/
|
|
private isClientIpAllowed(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)!;
|
|
}
|
|
|
|
// Check IP against route security settings
|
|
const ipAllowList = route.security.ipAllowList;
|
|
const ipBlockList = route.security.ipBlockList;
|
|
|
|
const allowed = this.isIPAuthorized(clientIp, ipAllowList, ipBlockList);
|
|
|
|
// Cache the result
|
|
routeCache.set(clientIp, allowed);
|
|
|
|
return allowed;
|
|
}
|
|
|
|
/**
|
|
* Check if request is within rate limit
|
|
*
|
|
* @param route - The route to check
|
|
* @param context - The request context
|
|
* @returns Whether the 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;
|
|
}
|
|
|
|
/**
|
|
* Validate HTTP Basic Authentication
|
|
*
|
|
* @param route - The route to check
|
|
* @param authHeader - The Authorization header
|
|
* @returns Whether authentication is valid
|
|
*/
|
|
public validateBasicAuth(route: IRouteConfig, authHeader?: string): boolean {
|
|
// Skip if basic auth not enabled for route
|
|
if (!route.security?.basicAuth?.enabled) {
|
|
return true;
|
|
}
|
|
|
|
// No auth header means auth failed
|
|
if (!authHeader) {
|
|
return false;
|
|
}
|
|
|
|
// Parse auth header
|
|
const credentials = parseBasicAuthHeader(authHeader);
|
|
if (!credentials) {
|
|
return false;
|
|
}
|
|
|
|
// Check credentials against configured users
|
|
const { username, password } = credentials;
|
|
const users = route.security.basicAuth.users;
|
|
|
|
return users.some(user =>
|
|
user.username === username && user.password === password
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Clean up caches to prevent memory leaks
|
|
*/
|
|
private cleanupCaches(): void {
|
|
// Clean up rate limits
|
|
cleanupExpiredRateLimits(this.rateLimits, this.logger);
|
|
|
|
// IP filter cache doesn't need cleanup (tied to routes)
|
|
}
|
|
|
|
/**
|
|
* Clear all IP tracking data (for shutdown)
|
|
*/
|
|
public clearIPTracking(): void {
|
|
this.connectionsByIP.clear();
|
|
this.rateLimits.clear();
|
|
this.ipFilterCache.clear();
|
|
|
|
if (this.cleanupInterval) {
|
|
clearInterval(this.cleanupInterval);
|
|
this.cleanupInterval = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update routes for security checking
|
|
*
|
|
* @param routes - New routes to use
|
|
*/
|
|
public setRoutes(routes: IRouteConfig[]): void {
|
|
// Only clear the IP filter cache - route-specific
|
|
this.ipFilterCache.clear();
|
|
}
|
|
} |