feat(integration): components now play nicer with each other
This commit is contained in:
@ -28,6 +28,9 @@ export interface IHierarchicalRateLimits {
|
||||
// IP-specific rate limits (applied to specific IPs)
|
||||
ips?: Record<string, IRateLimitConfig>;
|
||||
|
||||
// Domain-specific rate limits (applied to specific email domains)
|
||||
domains?: Record<string, IRateLimitConfig>;
|
||||
|
||||
// Temporary blocks list and their expiry times
|
||||
blocks?: Record<string, number>; // IP to expiry timestamp
|
||||
}
|
||||
@ -86,6 +89,7 @@ export class UnifiedRateLimiter extends EventEmitter {
|
||||
private counters: Map<string, ILimitCounter> = new Map();
|
||||
private patternCounters: Map<string, ILimitCounter> = new Map();
|
||||
private ipCounters: Map<string, ILimitCounter> = new Map();
|
||||
private domainCounters: Map<string, ILimitCounter> = new Map();
|
||||
private cleanupInterval?: NodeJS.Timeout;
|
||||
private stats: IRateLimiterStats;
|
||||
|
||||
@ -221,6 +225,13 @@ export class UnifiedRateLimiter extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean domain counters
|
||||
for (const [key, counter] of this.domainCounters.entries()) {
|
||||
if (counter.lastReset < cutoff) {
|
||||
this.domainCounters.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
}
|
||||
@ -231,9 +242,10 @@ export class UnifiedRateLimiter extends EventEmitter {
|
||||
* @param ip IP address
|
||||
* @param recipients Number of recipients
|
||||
* @param pattern Matched pattern
|
||||
* @param domain Domain name for domain-specific limits
|
||||
* @returns Result of rate limit check
|
||||
*/
|
||||
public checkMessageLimit(email: string, ip: string, recipients: number, pattern?: string): IRateLimitResult {
|
||||
public checkMessageLimit(email: string, ip: string, recipients: number, pattern?: string, domain?: string): IRateLimitResult {
|
||||
// Check if IP is blocked
|
||||
if (this.isIpBlocked(ip)) {
|
||||
return {
|
||||
@ -257,6 +269,14 @@ export class UnifiedRateLimiter extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Check domain-specific limit if domain is provided
|
||||
if (domain) {
|
||||
const domainResult = this.checkDomainMessageLimit(domain);
|
||||
if (!domainResult.allowed) {
|
||||
return domainResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Check IP-specific limit
|
||||
const ipResult = this.checkIpMessageLimit(ip);
|
||||
if (!ipResult.allowed) {
|
||||
@ -264,7 +284,7 @@ export class UnifiedRateLimiter extends EventEmitter {
|
||||
}
|
||||
|
||||
// Check recipient limit
|
||||
const recipientResult = this.checkRecipientLimit(email, recipients, pattern);
|
||||
const recipientResult = this.checkRecipientLimit(email, recipients, pattern, domain);
|
||||
if (!recipientResult.allowed) {
|
||||
return recipientResult;
|
||||
}
|
||||
@ -403,6 +423,64 @@ export class UnifiedRateLimiter extends EventEmitter {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check domain-specific message rate limit
|
||||
* @param domain Domain to check
|
||||
*/
|
||||
private checkDomainMessageLimit(domain: string): IRateLimitResult {
|
||||
const now = Date.now();
|
||||
|
||||
// Get domain-specific limit or use global
|
||||
const domainConfig = this.config.domains?.[domain];
|
||||
const limit = domainConfig?.maxMessagesPerMinute || this.config.global.maxMessagesPerMinute!;
|
||||
|
||||
if (!limit) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Get or create counter
|
||||
let counter = this.domainCounters.get(domain);
|
||||
|
||||
if (!counter) {
|
||||
counter = {
|
||||
count: 0,
|
||||
lastReset: now,
|
||||
recipients: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
connections: 0
|
||||
};
|
||||
this.domainCounters.set(domain, 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);
|
||||
|
||||
logger.log('warn', `Domain ${domain} rate limit exceeded: ${counter.count}/${limit} messages per minute`);
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Domain "${domain}" message rate limit exceeded`,
|
||||
limit,
|
||||
current: counter.count,
|
||||
resetIn
|
||||
};
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
counter.count++;
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check IP-specific message rate limit
|
||||
* @param ip IP address
|
||||
@ -485,15 +563,22 @@ export class UnifiedRateLimiter extends EventEmitter {
|
||||
* @param email Email address
|
||||
* @param recipients Number of recipients
|
||||
* @param pattern Matched pattern
|
||||
* @param domain Domain name
|
||||
*/
|
||||
private checkRecipientLimit(email: string, recipients: number, pattern?: string): IRateLimitResult {
|
||||
// Get pattern-specific limit if available
|
||||
private checkRecipientLimit(email: string, recipients: number, pattern?: string, domain?: string): IRateLimitResult {
|
||||
// Get the most specific limit available
|
||||
let limit = this.config.global.maxRecipientsPerMessage!;
|
||||
|
||||
// Check pattern-specific limit
|
||||
if (pattern && this.config.patterns?.[pattern]?.maxRecipientsPerMessage) {
|
||||
limit = this.config.patterns[pattern].maxRecipientsPerMessage!;
|
||||
}
|
||||
|
||||
// Check domain-specific limit (overrides pattern if present)
|
||||
if (domain && this.config.domains?.[domain]?.maxRecipientsPerMessage) {
|
||||
limit = this.config.domains[domain].maxRecipientsPerMessage!;
|
||||
}
|
||||
|
||||
if (!limit) {
|
||||
return { allowed: true };
|
||||
}
|
||||
@ -923,4 +1008,46 @@ export class UnifiedRateLimiter extends EventEmitter {
|
||||
public getConfig(): IHierarchicalRateLimits {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply domain-specific rate limits
|
||||
* Merges domain limits with existing configuration
|
||||
* @param domain Domain name
|
||||
* @param limits Rate limit configuration for the domain
|
||||
*/
|
||||
public applyDomainLimits(domain: string, limits: IRateLimitConfig): void {
|
||||
if (!this.config.domains) {
|
||||
this.config.domains = {};
|
||||
}
|
||||
|
||||
// Merge the limits with any existing domain config
|
||||
this.config.domains[domain] = {
|
||||
...this.config.domains[domain],
|
||||
...limits
|
||||
};
|
||||
|
||||
logger.log('info', `Applied rate limits for domain ${domain}:`, limits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove domain-specific rate limits
|
||||
* @param domain Domain name
|
||||
*/
|
||||
public removeDomainLimits(domain: string): void {
|
||||
if (this.config.domains && this.config.domains[domain]) {
|
||||
delete this.config.domains[domain];
|
||||
// Also remove the counter
|
||||
this.domainCounters.delete(domain);
|
||||
logger.log('info', `Removed rate limits for domain ${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domain-specific rate limits
|
||||
* @param domain Domain name
|
||||
* @returns Domain rate limit config or undefined
|
||||
*/
|
||||
public getDomainLimits(domain: string): IRateLimitConfig | undefined {
|
||||
return this.config.domains?.[domain];
|
||||
}
|
||||
}
|
@ -142,15 +142,37 @@ export class CommandHandler implements ICommandHandler {
|
||||
|
||||
// For the ERR-01 test, an empty or invalid command is considered a syntax error (500)
|
||||
if (!command || command.trim().length === 0) {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR} Command not recognized`);
|
||||
// Record error for rate limiting
|
||||
const emailServer = this.smtpServer.getEmailServer();
|
||||
const rateLimiter = emailServer.getRateLimiter();
|
||||
const shouldBlock = rateLimiter.recordError(session.remoteAddress);
|
||||
|
||||
if (shouldBlock) {
|
||||
SmtpLogger.warn(`IP ${session.remoteAddress} blocked due to excessive errors`);
|
||||
this.sendResponse(socket, `421 Too many errors - connection blocked`);
|
||||
socket.end();
|
||||
} else {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR} Command not recognized`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle unknown commands - this should happen before sequence validation
|
||||
// RFC 5321: Use 500 for unrecognized commands, 501 for parameter errors
|
||||
if (!Object.values(SmtpCommand).includes(command.toUpperCase() as SmtpCommand)) {
|
||||
// Comply with RFC 5321 section 4.2.4: Use 500 for unrecognized commands
|
||||
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR} Command not recognized`);
|
||||
// Record error for rate limiting
|
||||
const emailServer = this.smtpServer.getEmailServer();
|
||||
const rateLimiter = emailServer.getRateLimiter();
|
||||
const shouldBlock = rateLimiter.recordError(session.remoteAddress);
|
||||
|
||||
if (shouldBlock) {
|
||||
SmtpLogger.warn(`IP ${session.remoteAddress} blocked due to excessive errors`);
|
||||
this.sendResponse(socket, `421 Too many errors - connection blocked`);
|
||||
socket.end();
|
||||
} else {
|
||||
// Comply with RFC 5321 section 4.2.4: Use 500 for unrecognized commands
|
||||
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR} Command not recognized`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@ -477,6 +499,12 @@ export class CommandHandler implements ICommandHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get rate limiter for message-level checks
|
||||
const emailServer = this.smtpServer.getEmailServer();
|
||||
const rateLimiter = emailServer.getRateLimiter();
|
||||
|
||||
// Note: Connection-level rate limiting is already handled in ConnectionManager
|
||||
|
||||
// Special handling for commands that include "MAIL FROM:" in the args
|
||||
let processedArgs = args;
|
||||
|
||||
@ -509,6 +537,26 @@ export class CommandHandler implements ICommandHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check message rate limits for this sender
|
||||
const senderAddress = validation.address || '';
|
||||
const senderDomain = senderAddress.includes('@') ? senderAddress.split('@')[1] : undefined;
|
||||
|
||||
// Check rate limits with domain context if available
|
||||
const messageResult = rateLimiter.checkMessageLimit(
|
||||
senderAddress,
|
||||
session.remoteAddress,
|
||||
1, // We don't know recipients yet, check with 1
|
||||
undefined, // No pattern matching for now
|
||||
senderDomain // Pass domain for domain-specific limits
|
||||
);
|
||||
|
||||
if (!messageResult.allowed) {
|
||||
SmtpLogger.warn(`Message rate limit exceeded for ${senderAddress} from IP ${session.remoteAddress}: ${messageResult.reason}`);
|
||||
// Use 421 for temporary rate limiting (client should retry later)
|
||||
this.sendResponse(socket, `421 ${messageResult.reason} - try again later`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Enhanced SIZE parameter handling
|
||||
if (validation.params && validation.params.SIZE) {
|
||||
const size = parseInt(validation.params.SIZE, 10);
|
||||
@ -619,6 +667,29 @@ export class CommandHandler implements ICommandHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check rate limits for recipients
|
||||
const emailServer = this.smtpServer.getEmailServer();
|
||||
const rateLimiter = emailServer.getRateLimiter();
|
||||
const recipientAddress = validation.address || '';
|
||||
const recipientDomain = recipientAddress.includes('@') ? recipientAddress.split('@')[1] : undefined;
|
||||
|
||||
// Check rate limits with accumulated recipient count
|
||||
const recipientCount = session.rcptTo.length + 1; // Including this new recipient
|
||||
const messageResult = rateLimiter.checkMessageLimit(
|
||||
session.mailFrom,
|
||||
session.remoteAddress,
|
||||
recipientCount,
|
||||
undefined, // No pattern matching for now
|
||||
recipientDomain // Pass recipient domain for domain-specific limits
|
||||
);
|
||||
|
||||
if (!messageResult.allowed) {
|
||||
SmtpLogger.warn(`Recipient rate limit exceeded for ${recipientAddress} from IP ${session.remoteAddress}: ${messageResult.reason}`);
|
||||
// Use 451 for temporary recipient rejection
|
||||
this.sendResponse(socket, `451 ${messageResult.reason} - try again later`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create recipient object
|
||||
const recipient: IEnvelopeRecipient = {
|
||||
address: validation.address || '',
|
||||
@ -864,7 +935,18 @@ export class CommandHandler implements ICommandHandler {
|
||||
session.username = username;
|
||||
this.sendResponse(socket, `${SmtpResponseCode.AUTHENTICATION_SUCCESSFUL} Authentication successful`);
|
||||
} else {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication failed`);
|
||||
// Record authentication failure for rate limiting
|
||||
const emailServer = this.smtpServer.getEmailServer();
|
||||
const rateLimiter = emailServer.getRateLimiter();
|
||||
const shouldBlock = rateLimiter.recordAuthFailure(session.remoteAddress);
|
||||
|
||||
if (shouldBlock) {
|
||||
SmtpLogger.warn(`IP ${session.remoteAddress} blocked due to excessive authentication failures`);
|
||||
this.sendResponse(socket, `421 Too many authentication failures - connection blocked`);
|
||||
socket.end();
|
||||
} else {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication failed`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
SmtpLogger.error(`AUTH PLAIN error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
@ -945,7 +1027,18 @@ export class CommandHandler implements ICommandHandler {
|
||||
session.username = username;
|
||||
this.sendResponse(socket, `${SmtpResponseCode.AUTHENTICATION_SUCCESSFUL} Authentication successful`);
|
||||
} else {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication failed`);
|
||||
// Record authentication failure for rate limiting
|
||||
const emailServer = this.smtpServer.getEmailServer();
|
||||
const rateLimiter = emailServer.getRateLimiter();
|
||||
const shouldBlock = rateLimiter.recordAuthFailure(session.remoteAddress);
|
||||
|
||||
if (shouldBlock) {
|
||||
SmtpLogger.warn(`IP ${session.remoteAddress} blocked due to excessive authentication failures`);
|
||||
this.sendResponse(socket, `421 Too many authentication failures - connection blocked`);
|
||||
socket.end();
|
||||
} else {
|
||||
this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication failed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -298,19 +298,20 @@ export class ConnectionManager implements IConnectionManager {
|
||||
// Get client IP
|
||||
const remoteAddress = socket.remoteAddress || '0.0.0.0';
|
||||
|
||||
// Check rate limits by IP
|
||||
if (this.isIPRateLimited(remoteAddress)) {
|
||||
this.rejectConnection(socket, 'Rate limit exceeded');
|
||||
// Use UnifiedRateLimiter for connection rate limiting
|
||||
const emailServer = this.smtpServer.getEmailServer();
|
||||
const rateLimiter = emailServer.getRateLimiter();
|
||||
|
||||
// Check connection limit with UnifiedRateLimiter
|
||||
const connectionResult = rateLimiter.recordConnection(remoteAddress);
|
||||
if (!connectionResult.allowed) {
|
||||
this.rejectConnection(socket, connectionResult.reason || 'Rate limit exceeded');
|
||||
this.connectionStats.rejectedConnections++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check per-IP connection limit
|
||||
if (this.hasReachedIPConnectionLimit(remoteAddress)) {
|
||||
this.rejectConnection(socket, 'Too many connections from this IP');
|
||||
this.connectionStats.rejectedConnections++;
|
||||
return;
|
||||
}
|
||||
// Still track IP connections locally for cleanup purposes
|
||||
this.trackIPConnection(remoteAddress);
|
||||
|
||||
// Check if maximum global connections reached
|
||||
if (this.hasReachedMaxConnections()) {
|
||||
@ -454,19 +455,20 @@ export class ConnectionManager implements IConnectionManager {
|
||||
// Get client IP
|
||||
const remoteAddress = socket.remoteAddress || '0.0.0.0';
|
||||
|
||||
// Check rate limits by IP
|
||||
if (this.isIPRateLimited(remoteAddress)) {
|
||||
this.rejectConnection(socket, 'Rate limit exceeded');
|
||||
// Use UnifiedRateLimiter for connection rate limiting
|
||||
const emailServer = this.smtpServer.getEmailServer();
|
||||
const rateLimiter = emailServer.getRateLimiter();
|
||||
|
||||
// Check connection limit with UnifiedRateLimiter
|
||||
const connectionResult = rateLimiter.recordConnection(remoteAddress);
|
||||
if (!connectionResult.allowed) {
|
||||
this.rejectConnection(socket, connectionResult.reason || 'Rate limit exceeded');
|
||||
this.connectionStats.rejectedConnections++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check per-IP connection limit
|
||||
if (this.hasReachedIPConnectionLimit(remoteAddress)) {
|
||||
this.rejectConnection(socket, 'Too many connections from this IP');
|
||||
this.connectionStats.rejectedConnections++;
|
||||
return;
|
||||
}
|
||||
// Still track IP connections locally for cleanup purposes
|
||||
this.trackIPConnection(remoteAddress);
|
||||
|
||||
// Check if maximum global connections reached
|
||||
if (this.hasReachedMaxConnections()) {
|
||||
|
Reference in New Issue
Block a user