import * as plugins from '../plugins.js'; import { EventEmitter } from 'node:events'; import { logger } from '../logger.js'; import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../security/index.js'; /** * Interface for rate limit configuration */ export interface IRateLimitConfig { maxMessagesPerMinute?: number; maxRecipientsPerMessage?: number; maxConnectionsPerIP?: number; maxErrorsPerIP?: number; maxAuthFailuresPerIP?: number; blockDuration?: number; // in milliseconds } /** * Interface for hierarchical rate limits */ export interface IHierarchicalRateLimits { // Global rate limits (applied to all traffic) global: IRateLimitConfig; // Pattern-specific rate limits (applied to matching patterns) patterns?: Record; // IP-specific rate limits (applied to specific IPs) ips?: Record; // Temporary blocks list and their expiry times blocks?: Record; // IP to expiry timestamp } /** * Counter interface for rate limiting */ interface ILimitCounter { count: number; lastReset: number; recipients: number; errors: number; authFailures: number; connections: number; } /** * Rate limiter statistics */ export interface IRateLimiterStats { activeCounters: number; totalBlocked: number; currentlyBlocked: number; byPattern: Record; byIp: Record; } /** * Result of a rate limit check */ export interface IRateLimitResult { allowed: boolean; reason?: string; limit?: number; current?: number; resetIn?: number; // milliseconds until reset } /** * Unified rate limiter for all email processing modes */ export class UnifiedRateLimiter extends EventEmitter { private config: IHierarchicalRateLimits; private counters: Map = new Map(); private patternCounters: Map = new Map(); private ipCounters: Map = new Map(); private cleanupInterval?: NodeJS.Timeout; private stats: IRateLimiterStats; /** * Create a new unified rate limiter * @param config Rate limit configuration */ constructor(config: IHierarchicalRateLimits) { super(); // Set default configuration this.config = { global: { maxMessagesPerMinute: config.global.maxMessagesPerMinute || 100, maxRecipientsPerMessage: config.global.maxRecipientsPerMessage || 100, maxConnectionsPerIP: config.global.maxConnectionsPerIP || 20, maxErrorsPerIP: config.global.maxErrorsPerIP || 10, maxAuthFailuresPerIP: config.global.maxAuthFailuresPerIP || 5, blockDuration: config.global.blockDuration || 3600000 // 1 hour }, patterns: config.patterns || {}, ips: config.ips || {}, blocks: config.blocks || {} }; // Initialize statistics this.stats = { activeCounters: 0, totalBlocked: 0, currentlyBlocked: 0, byPattern: {}, byIp: {} }; // Start cleanup interval this.startCleanupInterval(); } /** * Start the cleanup interval */ private startCleanupInterval(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); } // Run cleanup every minute this.cleanupInterval = setInterval(() => this.cleanup(), 60000); } /** * Stop the cleanup interval */ public stop(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = undefined; } } /** * Clean up expired counters and blocks */ private cleanup(): void { const now = Date.now(); // Clean up expired blocks if (this.config.blocks) { for (const [ip, expiry] of Object.entries(this.config.blocks)) { if (expiry <= now) { delete this.config.blocks[ip]; logger.log('info', `Rate limit block expired for IP ${ip}`); // Update statistics if (this.stats.byIp[ip]) { this.stats.byIp[ip].blocked = false; } this.stats.currentlyBlocked--; } } } // Clean up old counters (older than 10 minutes) const cutoff = now - 600000; // Clean global counters for (const [key, counter] of this.counters.entries()) { if (counter.lastReset < cutoff) { this.counters.delete(key); } } // Clean pattern counters for (const [key, counter] of this.patternCounters.entries()) { if (counter.lastReset < cutoff) { this.patternCounters.delete(key); } } // Clean IP counters for (const [key, counter] of this.ipCounters.entries()) { if (counter.lastReset < cutoff) { this.ipCounters.delete(key); } } // Update statistics this.updateStats(); } /** * Check if a message is allowed by rate limits * @param email Email address * @param ip IP address * @param recipients Number of recipients * @param pattern Matched pattern * @returns Result of rate limit check */ public checkMessageLimit(email: string, ip: string, recipients: number, pattern?: string): IRateLimitResult { // Check if IP is blocked if (this.isIpBlocked(ip)) { return { allowed: false, reason: 'IP is blocked', resetIn: this.getBlockReleaseTime(ip) }; } // Check global message rate limit const globalResult = this.checkGlobalMessageLimit(email); if (!globalResult.allowed) { return globalResult; } // Check pattern-specific limit if pattern is provided if (pattern) { const patternResult = this.checkPatternMessageLimit(pattern); if (!patternResult.allowed) { return patternResult; } } // Check IP-specific limit const ipResult = this.checkIpMessageLimit(ip); if (!ipResult.allowed) { return ipResult; } // Check recipient limit const recipientResult = this.checkRecipientLimit(email, recipients, pattern); if (!recipientResult.allowed) { return recipientResult; } // All checks passed return { allowed: true }; } /** * Check global message rate limit * @param email Email address */ private checkGlobalMessageLimit(email: string): IRateLimitResult { const now = Date.now(); const limit = this.config.global.maxMessagesPerMinute!; if (!limit) { return { allowed: true }; } // Get or create counter const key = 'global'; let counter = this.counters.get(key); if (!counter) { counter = { count: 0, lastReset: now, recipients: 0, errors: 0, authFailures: 0, connections: 0 }; this.counters.set(key, counter); } // Check if counter needs to be reset if (now - counter.lastReset >= 60000) { counter.count = 0; counter.lastReset = now; } // Check if limit is exceeded if (counter.count >= limit) { // Calculate reset time const resetIn = 60000 - (now - counter.lastReset); return { allowed: false, reason: 'Global message rate limit exceeded', limit, current: counter.count, resetIn }; } // Increment counter counter.count++; // Update statistics this.updateStats(); return { allowed: true }; } /** * Check pattern-specific message rate limit * @param pattern Pattern to check */ private checkPatternMessageLimit(pattern: string): IRateLimitResult { const now = Date.now(); // Get pattern-specific limit or use global const patternConfig = this.config.patterns?.[pattern]; const limit = patternConfig?.maxMessagesPerMinute || this.config.global.maxMessagesPerMinute!; if (!limit) { return { allowed: true }; } // Get or create counter let counter = this.patternCounters.get(pattern); if (!counter) { counter = { count: 0, lastReset: now, recipients: 0, errors: 0, authFailures: 0, connections: 0 }; this.patternCounters.set(pattern, counter); // Initialize pattern stats if needed if (!this.stats.byPattern[pattern]) { this.stats.byPattern[pattern] = { messagesPerMinute: 0, totalMessages: 0, totalBlocked: 0 }; } } // Check if counter needs to be reset if (now - counter.lastReset >= 60000) { counter.count = 0; counter.lastReset = now; } // Check if limit is exceeded if (counter.count >= limit) { // Calculate reset time const resetIn = 60000 - (now - counter.lastReset); // Update statistics this.stats.byPattern[pattern].totalBlocked++; this.stats.totalBlocked++; return { allowed: false, reason: `Pattern "${pattern}" message rate limit exceeded`, limit, current: counter.count, resetIn }; } // Increment counter counter.count++; // Update statistics this.stats.byPattern[pattern].messagesPerMinute = counter.count; this.stats.byPattern[pattern].totalMessages++; return { allowed: true }; } /** * Check IP-specific message rate limit * @param ip IP address */ private checkIpMessageLimit(ip: string): IRateLimitResult { const now = Date.now(); // Get IP-specific limit or use global const ipConfig = this.config.ips?.[ip]; const limit = ipConfig?.maxMessagesPerMinute || this.config.global.maxMessagesPerMinute!; if (!limit) { return { allowed: true }; } // Get or create counter let counter = this.ipCounters.get(ip); if (!counter) { counter = { count: 0, lastReset: now, recipients: 0, errors: 0, authFailures: 0, connections: 0 }; this.ipCounters.set(ip, counter); // Initialize IP stats if needed if (!this.stats.byIp[ip]) { this.stats.byIp[ip] = { messagesPerMinute: 0, totalMessages: 0, totalBlocked: 0, connections: 0, errors: 0, authFailures: 0, blocked: false }; } } // Check if counter needs to be reset if (now - counter.lastReset >= 60000) { counter.count = 0; counter.lastReset = now; } // Check if limit is exceeded if (counter.count >= limit) { // Calculate reset time const resetIn = 60000 - (now - counter.lastReset); // Update statistics this.stats.byIp[ip].totalBlocked++; this.stats.totalBlocked++; return { allowed: false, reason: `IP ${ip} message rate limit exceeded`, limit, current: counter.count, resetIn }; } // Increment counter counter.count++; // Update statistics this.stats.byIp[ip].messagesPerMinute = counter.count; this.stats.byIp[ip].totalMessages++; return { allowed: true }; } /** * Check recipient limit * @param email Email address * @param recipients Number of recipients * @param pattern Matched pattern */ private checkRecipientLimit(email: string, recipients: number, pattern?: string): IRateLimitResult { // Get pattern-specific limit if available let limit = this.config.global.maxRecipientsPerMessage!; if (pattern && this.config.patterns?.[pattern]?.maxRecipientsPerMessage) { limit = this.config.patterns[pattern].maxRecipientsPerMessage!; } if (!limit) { return { allowed: true }; } // Check if limit is exceeded if (recipients > limit) { return { allowed: false, reason: 'Recipient limit exceeded', limit, current: recipients }; } return { allowed: true }; } /** * Record a connection from an IP * @param ip IP address * @returns Result of rate limit check */ public recordConnection(ip: string): IRateLimitResult { const now = Date.now(); // Check if IP is blocked if (this.isIpBlocked(ip)) { return { allowed: false, reason: 'IP is blocked', resetIn: this.getBlockReleaseTime(ip) }; } // Get IP-specific limit or use global const ipConfig = this.config.ips?.[ip]; const limit = ipConfig?.maxConnectionsPerIP || this.config.global.maxConnectionsPerIP!; if (!limit) { return { allowed: true }; } // Get or create counter let counter = this.ipCounters.get(ip); if (!counter) { counter = { count: 0, lastReset: now, recipients: 0, errors: 0, authFailures: 0, connections: 0 }; this.ipCounters.set(ip, counter); // Initialize IP stats if needed if (!this.stats.byIp[ip]) { this.stats.byIp[ip] = { messagesPerMinute: 0, totalMessages: 0, totalBlocked: 0, connections: 0, errors: 0, authFailures: 0, blocked: false }; } } // Check if counter needs to be reset if (now - counter.lastReset >= 60000) { counter.connections = 0; counter.lastReset = now; } // Check if limit is exceeded if (counter.connections >= limit) { // Calculate reset time const resetIn = 60000 - (now - counter.lastReset); // Update statistics this.stats.byIp[ip].totalBlocked++; this.stats.totalBlocked++; return { allowed: false, reason: `IP ${ip} connection rate limit exceeded`, limit, current: counter.connections, resetIn }; } // Increment counter counter.connections++; // Update statistics this.stats.byIp[ip].connections = counter.connections; return { allowed: true }; } /** * Record an error from an IP * @param ip IP address * @returns True if IP should be blocked */ public recordError(ip: string): boolean { const now = Date.now(); // Get IP-specific limit or use global const ipConfig = this.config.ips?.[ip]; const limit = ipConfig?.maxErrorsPerIP || this.config.global.maxErrorsPerIP!; if (!limit) { return false; } // Get or create counter let counter = this.ipCounters.get(ip); if (!counter) { counter = { count: 0, lastReset: now, recipients: 0, errors: 0, authFailures: 0, connections: 0 }; this.ipCounters.set(ip, counter); // Initialize IP stats if needed if (!this.stats.byIp[ip]) { this.stats.byIp[ip] = { messagesPerMinute: 0, totalMessages: 0, totalBlocked: 0, connections: 0, errors: 0, authFailures: 0, blocked: false }; } } // Check if counter needs to be reset if (now - counter.lastReset >= 60000) { counter.errors = 0; counter.lastReset = now; } // Increment counter counter.errors++; // Update statistics this.stats.byIp[ip].errors = counter.errors; // Check if limit is exceeded if (counter.errors >= limit) { // Block the IP this.blockIp(ip); logger.log('warn', `IP ${ip} blocked due to excessive errors (${counter.errors}/${limit})`); SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.WARN, type: SecurityEventType.RATE_LIMITING, message: 'IP blocked due to excessive errors', ipAddress: ip, details: { errors: counter.errors, limit }, success: false }); return true; } return false; } /** * Record an authentication failure from an IP * @param ip IP address * @returns True if IP should be blocked */ public recordAuthFailure(ip: string): boolean { const now = Date.now(); // Get IP-specific limit or use global const ipConfig = this.config.ips?.[ip]; const limit = ipConfig?.maxAuthFailuresPerIP || this.config.global.maxAuthFailuresPerIP!; if (!limit) { return false; } // Get or create counter let counter = this.ipCounters.get(ip); if (!counter) { counter = { count: 0, lastReset: now, recipients: 0, errors: 0, authFailures: 0, connections: 0 }; this.ipCounters.set(ip, counter); // Initialize IP stats if needed if (!this.stats.byIp[ip]) { this.stats.byIp[ip] = { messagesPerMinute: 0, totalMessages: 0, totalBlocked: 0, connections: 0, errors: 0, authFailures: 0, blocked: false }; } } // Check if counter needs to be reset if (now - counter.lastReset >= 60000) { counter.authFailures = 0; counter.lastReset = now; } // Increment counter counter.authFailures++; // Update statistics this.stats.byIp[ip].authFailures = counter.authFailures; // Check if limit is exceeded if (counter.authFailures >= limit) { // Block the IP this.blockIp(ip); logger.log('warn', `IP ${ip} blocked due to excessive authentication failures (${counter.authFailures}/${limit})`); SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.WARN, type: SecurityEventType.AUTHENTICATION, message: 'IP blocked due to excessive authentication failures', ipAddress: ip, details: { authFailures: counter.authFailures, limit }, success: false }); return true; } return false; } /** * Block an IP address * @param ip IP address to block * @param duration Override the default block duration (milliseconds) */ public blockIp(ip: string, duration?: number): void { if (!this.config.blocks) { this.config.blocks = {}; } // Set block expiry time const expiry = Date.now() + (duration || this.config.global.blockDuration || 3600000); this.config.blocks[ip] = expiry; // Update statistics if (!this.stats.byIp[ip]) { this.stats.byIp[ip] = { messagesPerMinute: 0, totalMessages: 0, totalBlocked: 0, connections: 0, errors: 0, authFailures: 0, blocked: false }; } this.stats.byIp[ip].blocked = true; this.stats.currentlyBlocked++; // Emit event this.emit('ipBlocked', { ip, expiry, duration: duration || this.config.global.blockDuration }); logger.log('warn', `IP ${ip} blocked until ${new Date(expiry).toISOString()}`); } /** * Unblock an IP address * @param ip IP address to unblock */ public unblockIp(ip: string): void { if (!this.config.blocks) { return; } // Remove block delete this.config.blocks[ip]; // Update statistics if (this.stats.byIp[ip]) { this.stats.byIp[ip].blocked = false; this.stats.currentlyBlocked--; } // Emit event this.emit('ipUnblocked', { ip }); logger.log('info', `IP ${ip} unblocked`); } /** * Check if an IP is blocked * @param ip IP address to check */ public isIpBlocked(ip: string): boolean { if (!this.config.blocks) { return false; } // Check if IP is in blocks if (!(ip in this.config.blocks)) { return false; } // Check if block has expired const expiry = this.config.blocks[ip]; if (expiry <= Date.now()) { // Remove expired block delete this.config.blocks[ip]; // Update statistics if (this.stats.byIp[ip]) { this.stats.byIp[ip].blocked = false; this.stats.currentlyBlocked--; } return false; } return true; } /** * Get the time until a block is released * @param ip IP address * @returns Milliseconds until release or 0 if not blocked */ public getBlockReleaseTime(ip: string): number { if (!this.config.blocks || !(ip in this.config.blocks)) { return 0; } const expiry = this.config.blocks[ip]; const now = Date.now(); return expiry > now ? expiry - now : 0; } /** * Update rate limiter statistics */ private updateStats(): void { // Update active counters count this.stats.activeCounters = this.counters.size + this.patternCounters.size + this.ipCounters.size; // Emit statistics update this.emit('statsUpdated', this.stats); } /** * Get rate limiter statistics */ public getStats(): IRateLimiterStats { return { ...this.stats }; } /** * Update rate limiter configuration * @param config New configuration */ public updateConfig(config: Partial): void { if (config.global) { this.config.global = { ...this.config.global, ...config.global }; } if (config.patterns) { this.config.patterns = { ...this.config.patterns, ...config.patterns }; } if (config.ips) { this.config.ips = { ...this.config.ips, ...config.ips }; } logger.log('info', 'Rate limiter configuration updated'); } /** * Get configuration for debugging */ public getConfig(): IHierarchicalRateLimits { return { ...this.config }; } }