/** * SMTP Security Handler * Responsible for security aspects including IP reputation checking, * email validation, and authentication */ import * as plugins from '../../../plugins.js'; import type { ISmtpSession, ISmtpAuth } from './interfaces.js'; import type { ISecurityHandler } from './interfaces.js'; import { SmtpLogger } from './utils/logging.js'; import { SecurityEventType, SecurityLogLevel } from './constants.js'; import { isValidEmail } from './utils/validation.js'; import { getSocketDetails, getTlsDetails } from './utils/helpers.js'; import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js'; /** * Interface for IP denylist entry */ interface IIpDenylistEntry { ip: string; reason: string; expiresAt?: number; } /** * Handles security aspects for SMTP server */ export class SecurityHandler implements ISecurityHandler { /** * Email server reference */ private emailServer: UnifiedEmailServer; /** * IP reputation service */ private ipReputationService?: any; /** * Authentication options */ private authOptions?: { required: boolean; methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[]; validateUser?: (username: string, password: string) => Promise; }; /** * Simple in-memory IP denylist */ private ipDenylist: IIpDenylistEntry[] = []; /** * Creates a new security handler * @param emailServer - Email server reference * @param ipReputationService - Optional IP reputation service * @param authOptions - Authentication options */ constructor( emailServer: UnifiedEmailServer, ipReputationService?: any, authOptions?: { required: boolean; methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[]; validateUser?: (username: string, password: string) => Promise; } ) { this.emailServer = emailServer; this.ipReputationService = ipReputationService; this.authOptions = authOptions; // Clean expired denylist entries periodically setInterval(() => this.cleanExpiredDenylistEntries(), 60000); // Every minute } /** * Check IP reputation for a connection * @param socket - Client socket * @returns Promise that resolves to true if IP is allowed, false if blocked */ public async checkIpReputation(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise { const socketDetails = getSocketDetails(socket); const ip = socketDetails.remoteAddress; // Check local denylist first if (this.isIpDenylisted(ip)) { // Log the blocked connection this.logSecurityEvent( SecurityEventType.IP_REPUTATION, SecurityLogLevel.WARN, `Connection blocked from denylisted IP: ${ip}`, { reason: this.getDenylistReason(ip) } ); return false; } // If no reputation service, allow by default if (!this.ipReputationService) { return true; } try { // Check with IP reputation service const reputationResult = await this.ipReputationService.checkIp(ip); if (!reputationResult.allowed) { // Add to local denylist temporarily this.addToDenylist(ip, reputationResult.reason, 3600000); // 1 hour // Log the blocked connection this.logSecurityEvent( SecurityEventType.IP_REPUTATION, SecurityLogLevel.WARN, `Connection blocked by reputation service: ${ip}`, { reason: reputationResult.reason, score: reputationResult.score, categories: reputationResult.categories } ); return false; } // Log the allowed connection this.logSecurityEvent( SecurityEventType.IP_REPUTATION, SecurityLogLevel.INFO, `IP reputation check passed: ${ip}`, { score: reputationResult.score, categories: reputationResult.categories } ); return true; } catch (error) { // Log the error SmtpLogger.error(`IP reputation check error: ${error instanceof Error ? error.message : String(error)}`, { ip, error: error instanceof Error ? error : new Error(String(error)) }); // Allow the connection on error (fail open) return true; } } /** * Validate an email address * @param email - Email address to validate * @returns Whether the email address is valid */ public isValidEmail(email: string): boolean { return isValidEmail(email); } /** * Validate authentication credentials * @param session - SMTP session * @param username - Username * @param password - Password * @param method - Authentication method * @returns Promise that resolves to true if authenticated */ public async authenticate(session: ISmtpSession, username: string, password: string, method: string): Promise { // Check if authentication is enabled if (!this.authOptions) { this.logSecurityEvent( SecurityEventType.AUTHENTICATION, SecurityLogLevel.WARN, 'Authentication attempt when auth is disabled', { username, method, sessionId: session.id, ip: session.remoteAddress } ); return false; } // Check if method is supported if (!this.authOptions.methods.includes(method as any)) { this.logSecurityEvent( SecurityEventType.AUTHENTICATION, SecurityLogLevel.WARN, `Unsupported authentication method: ${method}`, { username, method, sessionId: session.id, ip: session.remoteAddress } ); return false; } // Check if TLS is active (should be required for auth) if (!session.useTLS) { this.logSecurityEvent( SecurityEventType.AUTHENTICATION, SecurityLogLevel.WARN, 'Authentication attempt without TLS', { username, method, sessionId: session.id, ip: session.remoteAddress } ); return false; } try { let authenticated = false; // Use custom validation function if provided if (this.authOptions.validateUser) { authenticated = await this.authOptions.validateUser(username, password); } else { // Default behavior - no authentication authenticated = false; } // Log the authentication result this.logSecurityEvent( SecurityEventType.AUTHENTICATION, authenticated ? SecurityLogLevel.INFO : SecurityLogLevel.WARN, authenticated ? 'Authentication successful' : 'Authentication failed', { username, method, sessionId: session.id, ip: session.remoteAddress } ); return authenticated; } catch (error) { // Log authentication error this.logSecurityEvent( SecurityEventType.AUTHENTICATION, SecurityLogLevel.ERROR, `Authentication error: ${error instanceof Error ? error.message : String(error)}`, { username, method, sessionId: session.id, ip: session.remoteAddress, error: error instanceof Error ? error.message : String(error) } ); return false; } } /** * Log a security event * @param event - Event type * @param level - Log level * @param details - Event details */ public logSecurityEvent(event: string, level: string, message: string, details: Record): void { SmtpLogger.logSecurityEvent( level as SecurityLogLevel, event as SecurityEventType, message, details, details.ip, details.domain, details.success ); } /** * Add an IP to the denylist * @param ip - IP address * @param reason - Reason for denylisting * @param duration - Duration in milliseconds (optional, indefinite if not specified) */ private addToDenylist(ip: string, reason: string, duration?: number): void { // Remove existing entry if present this.ipDenylist = this.ipDenylist.filter(entry => entry.ip !== ip); // Create new entry const entry: IIpDenylistEntry = { ip, reason, expiresAt: duration ? Date.now() + duration : undefined }; // Add to denylist this.ipDenylist.push(entry); // Log the action this.logSecurityEvent( SecurityEventType.ACCESS_CONTROL, SecurityLogLevel.INFO, `Added IP to denylist: ${ip}`, { ip, reason, duration: duration ? `${duration / 1000} seconds` : 'indefinite' } ); } /** * Check if an IP is denylisted * @param ip - IP address * @returns Whether the IP is denylisted */ private isIpDenylisted(ip: string): boolean { const entry = this.ipDenylist.find(e => e.ip === ip); if (!entry) { return false; } // Check if entry has expired if (entry.expiresAt && entry.expiresAt < Date.now()) { // Remove expired entry this.ipDenylist = this.ipDenylist.filter(e => e !== entry); return false; } return true; } /** * Get the reason an IP was denylisted * @param ip - IP address * @returns Reason for denylisting or undefined if not denylisted */ private getDenylistReason(ip: string): string | undefined { const entry = this.ipDenylist.find(e => e.ip === ip); return entry?.reason; } /** * Clean expired denylist entries */ private cleanExpiredDenylistEntries(): void { const now = Date.now(); const initialCount = this.ipDenylist.length; this.ipDenylist = this.ipDenylist.filter(entry => { return !entry.expiresAt || entry.expiresAt > now; }); const removedCount = initialCount - this.ipDenylist.length; if (removedCount > 0) { this.logSecurityEvent( SecurityEventType.ACCESS_CONTROL, SecurityLogLevel.INFO, `Cleaned up ${removedCount} expired denylist entries`, { remainingCount: this.ipDenylist.length } ); } } }