309 lines
7.6 KiB
TypeScript
309 lines
7.6 KiB
TypeScript
|
import * as plugins from '../../plugins.js';
|
||
|
import {
|
||
|
matchIpPattern,
|
||
|
ipToNumber,
|
||
|
matchIpCidr
|
||
|
} from './route-utils.js';
|
||
|
|
||
|
/**
|
||
|
* Security utilities for IP validation, rate limiting,
|
||
|
* authentication, and other security features
|
||
|
*/
|
||
|
|
||
|
/**
|
||
|
* Result of IP validation
|
||
|
*/
|
||
|
export interface IIpValidationResult {
|
||
|
allowed: boolean;
|
||
|
reason?: string;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* IP connection tracking information
|
||
|
*/
|
||
|
export interface IIpConnectionInfo {
|
||
|
connections: Set<string>; // ConnectionIDs
|
||
|
timestamps: number[]; // Connection timestamps
|
||
|
ipVariants: string[]; // Normalized IP variants (e.g., ::ffff:127.0.0.1 and 127.0.0.1)
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Rate limit tracking
|
||
|
*/
|
||
|
export interface IRateLimitInfo {
|
||
|
count: number;
|
||
|
expiry: number;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Logger interface for security utilities
|
||
|
*/
|
||
|
export interface ISecurityLogger {
|
||
|
info: (message: string, ...args: any[]) => void;
|
||
|
warn: (message: string, ...args: any[]) => void;
|
||
|
error: (message: string, ...args: any[]) => void;
|
||
|
debug?: (message: string, ...args: any[]) => void;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Normalize IP addresses for comparison
|
||
|
* Handles IPv4-mapped IPv6 addresses (::ffff:127.0.0.1)
|
||
|
*
|
||
|
* @param ip IP address to normalize
|
||
|
* @returns Array of equivalent IP representations
|
||
|
*/
|
||
|
export function normalizeIP(ip: string): string[] {
|
||
|
if (!ip) return [];
|
||
|
|
||
|
// Handle IPv4-mapped IPv6 addresses (::ffff:127.0.0.1)
|
||
|
if (ip.startsWith('::ffff:')) {
|
||
|
const ipv4 = ip.slice(7);
|
||
|
return [ip, ipv4];
|
||
|
}
|
||
|
|
||
|
// Handle IPv4 addresses by also checking IPv4-mapped form
|
||
|
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
|
||
|
return [ip, `::ffff:${ip}`];
|
||
|
}
|
||
|
|
||
|
return [ip];
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Check if an IP is authorized based on allow and block lists
|
||
|
*
|
||
|
* @param ip - The IP address to check
|
||
|
* @param allowedIPs - Array of allowed IP patterns
|
||
|
* @param blockedIPs - Array of blocked IP patterns
|
||
|
* @returns Whether the IP is authorized
|
||
|
*/
|
||
|
export function isIPAuthorized(
|
||
|
ip: string,
|
||
|
allowedIPs: string[] = ['*'],
|
||
|
blockedIPs: string[] = []
|
||
|
): boolean {
|
||
|
// Skip IP validation if no rules
|
||
|
if (!ip || (allowedIPs.length === 0 && blockedIPs.length === 0)) {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
// First check if IP is blocked - blocked IPs take precedence
|
||
|
if (blockedIPs.length > 0) {
|
||
|
for (const pattern of blockedIPs) {
|
||
|
if (matchIpPattern(pattern, ip)) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// If allowed IPs list has wildcard, all non-blocked IPs are allowed
|
||
|
if (allowedIPs.includes('*')) {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
// Then check if IP is allowed in the explicit allow list
|
||
|
if (allowedIPs.length > 0) {
|
||
|
for (const pattern of allowedIPs) {
|
||
|
if (matchIpPattern(pattern, ip)) {
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
// If allowedIPs is specified but no match, deny access
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
// Default allow if no explicit allow list
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Check if an IP exceeds maximum connections
|
||
|
*
|
||
|
* @param ip - The IP address to check
|
||
|
* @param ipConnectionsMap - Map of IPs to connection info
|
||
|
* @param maxConnectionsPerIP - Maximum allowed connections per IP
|
||
|
* @returns Result with allowed status and reason if blocked
|
||
|
*/
|
||
|
export function checkMaxConnections(
|
||
|
ip: string,
|
||
|
ipConnectionsMap: Map<string, IIpConnectionInfo>,
|
||
|
maxConnectionsPerIP: number
|
||
|
): IIpValidationResult {
|
||
|
if (!ipConnectionsMap.has(ip)) {
|
||
|
return { allowed: true };
|
||
|
}
|
||
|
|
||
|
const connectionCount = ipConnectionsMap.get(ip)!.connections.size;
|
||
|
|
||
|
if (connectionCount >= maxConnectionsPerIP) {
|
||
|
return {
|
||
|
allowed: false,
|
||
|
reason: `Maximum connections per IP (${maxConnectionsPerIP}) exceeded`
|
||
|
};
|
||
|
}
|
||
|
|
||
|
return { allowed: true };
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Check if an IP exceeds connection rate limit
|
||
|
*
|
||
|
* @param ip - The IP address to check
|
||
|
* @param ipConnectionsMap - Map of IPs to connection info
|
||
|
* @param rateLimit - Maximum connections per minute
|
||
|
* @returns Result with allowed status and reason if blocked
|
||
|
*/
|
||
|
export function checkConnectionRate(
|
||
|
ip: string,
|
||
|
ipConnectionsMap: Map<string, IIpConnectionInfo>,
|
||
|
rateLimit: number
|
||
|
): IIpValidationResult {
|
||
|
const now = Date.now();
|
||
|
const minute = 60 * 1000;
|
||
|
|
||
|
// Get or create connection info
|
||
|
if (!ipConnectionsMap.has(ip)) {
|
||
|
const info: IIpConnectionInfo = {
|
||
|
connections: new Set(),
|
||
|
timestamps: [now],
|
||
|
ipVariants: normalizeIP(ip)
|
||
|
};
|
||
|
ipConnectionsMap.set(ip, info);
|
||
|
return { allowed: true };
|
||
|
}
|
||
|
|
||
|
// Get timestamps and filter out entries older than 1 minute
|
||
|
const info = ipConnectionsMap.get(ip)!;
|
||
|
const timestamps = info.timestamps.filter(time => now - time < minute);
|
||
|
timestamps.push(now);
|
||
|
info.timestamps = timestamps;
|
||
|
|
||
|
// Check if rate exceeds limit
|
||
|
if (timestamps.length > rateLimit) {
|
||
|
return {
|
||
|
allowed: false,
|
||
|
reason: `Connection rate limit (${rateLimit}/min) exceeded`
|
||
|
};
|
||
|
}
|
||
|
|
||
|
return { allowed: true };
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Track a connection for an IP
|
||
|
*
|
||
|
* @param ip - The IP address
|
||
|
* @param connectionId - The connection ID to track
|
||
|
* @param ipConnectionsMap - Map of IPs to connection info
|
||
|
*/
|
||
|
export function trackConnection(
|
||
|
ip: string,
|
||
|
connectionId: string,
|
||
|
ipConnectionsMap: Map<string, IIpConnectionInfo>
|
||
|
): void {
|
||
|
if (!ipConnectionsMap.has(ip)) {
|
||
|
ipConnectionsMap.set(ip, {
|
||
|
connections: new Set([connectionId]),
|
||
|
timestamps: [Date.now()],
|
||
|
ipVariants: normalizeIP(ip)
|
||
|
});
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const info = ipConnectionsMap.get(ip)!;
|
||
|
info.connections.add(connectionId);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Remove connection tracking for an IP
|
||
|
*
|
||
|
* @param ip - The IP address
|
||
|
* @param connectionId - The connection ID to remove
|
||
|
* @param ipConnectionsMap - Map of IPs to connection info
|
||
|
*/
|
||
|
export function removeConnection(
|
||
|
ip: string,
|
||
|
connectionId: string,
|
||
|
ipConnectionsMap: Map<string, IIpConnectionInfo>
|
||
|
): void {
|
||
|
if (!ipConnectionsMap.has(ip)) return;
|
||
|
|
||
|
const info = ipConnectionsMap.get(ip)!;
|
||
|
info.connections.delete(connectionId);
|
||
|
|
||
|
if (info.connections.size === 0) {
|
||
|
ipConnectionsMap.delete(ip);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Clean up expired rate limits
|
||
|
*
|
||
|
* @param rateLimits - Map of rate limits to clean up
|
||
|
* @param logger - Logger for debug messages
|
||
|
*/
|
||
|
export function cleanupExpiredRateLimits(
|
||
|
rateLimits: Map<string, Map<string, IRateLimitInfo>>,
|
||
|
logger?: ISecurityLogger
|
||
|
): void {
|
||
|
const now = Date.now();
|
||
|
let totalRemoved = 0;
|
||
|
|
||
|
for (const [routeId, routeLimits] of rateLimits.entries()) {
|
||
|
let removed = 0;
|
||
|
for (const [key, limit] of routeLimits.entries()) {
|
||
|
if (limit.expiry < now) {
|
||
|
routeLimits.delete(key);
|
||
|
removed++;
|
||
|
totalRemoved++;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (removed > 0 && logger?.debug) {
|
||
|
logger.debug(`Cleaned up ${removed} expired rate limits for route ${routeId}`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (totalRemoved > 0 && logger?.info) {
|
||
|
logger.info(`Cleaned up ${totalRemoved} expired rate limits total`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Generate basic auth header value from username and password
|
||
|
*
|
||
|
* @param username - The username
|
||
|
* @param password - The password
|
||
|
* @returns Base64 encoded basic auth string
|
||
|
*/
|
||
|
export function generateBasicAuthHeader(username: string, password: string): string {
|
||
|
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Parse basic auth header
|
||
|
*
|
||
|
* @param authHeader - The Authorization header value
|
||
|
* @returns Username and password, or null if invalid
|
||
|
*/
|
||
|
export function parseBasicAuthHeader(
|
||
|
authHeader: string
|
||
|
): { username: string; password: string } | null {
|
||
|
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
try {
|
||
|
const base64 = authHeader.slice(6); // Remove 'Basic '
|
||
|
const decoded = Buffer.from(base64, 'base64').toString();
|
||
|
const [username, password] = decoded.split(':');
|
||
|
|
||
|
if (!username || !password) {
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
return { username, password };
|
||
|
} catch (err) {
|
||
|
return null;
|
||
|
}
|
||
|
}
|