298 lines
9.0 KiB
TypeScript
298 lines
9.0 KiB
TypeScript
|
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<string, Map<string, boolean>> = new Map();
|
||
|
|
||
|
// Store rate limits per route and key
|
||
|
private rateLimits: Map<string, Map<string, { count: number, expiry: number }>> = 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;
|
||
|
}
|
||
|
}
|
||
|
}
|