345 lines
		
	
	
		
			9.9 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			345 lines
		
	
	
		
			9.9 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | /** | ||
|  |  * SMTP Security Handler | ||
|  |  * Responsible for security aspects including IP reputation checking,  | ||
|  |  * email validation, and authentication | ||
|  |  */ | ||
|  | 
 | ||
|  | import * as plugins from '../../../plugins.ts'; | ||
|  | import type { ISmtpSession, ISmtpAuth } from './interfaces.ts'; | ||
|  | import type { ISecurityHandler, ISmtpServer } from './interfaces.ts'; | ||
|  | import { SmtpLogger } from './utils/logging.ts'; | ||
|  | import { SecurityEventType, SecurityLogLevel } from './constants.ts'; | ||
|  | import { isValidEmail } from './utils/validation.ts'; | ||
|  | import { getSocketDetails, getTlsDetails } from './utils/helpers.ts'; | ||
|  | import { IPReputationChecker } from '../../../security/classes.ipreputationchecker.ts'; | ||
|  | 
 | ||
|  | /** | ||
|  |  * Interface for IP denylist entry | ||
|  |  */ | ||
|  | interface IIpDenylistEntry { | ||
|  |   ip: string; | ||
|  |   reason: string; | ||
|  |   expiresAt?: number; | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Handles security aspects for SMTP server | ||
|  |  */ | ||
|  | export class SecurityHandler implements ISecurityHandler { | ||
|  |   /** | ||
|  |    * Reference to the SMTP server instance | ||
|  |    */ | ||
|  |   private smtpServer: ISmtpServer; | ||
|  |    | ||
|  |   /** | ||
|  |    * IP reputation checker service | ||
|  |    */ | ||
|  |   private ipReputationService: IPReputationChecker; | ||
|  |    | ||
|  |   /** | ||
|  |    * Simple in-memory IP denylist | ||
|  |    */ | ||
|  |   private ipDenylist: IIpDenylistEntry[] = []; | ||
|  |    | ||
|  |   /** | ||
|  |    * Cleanup interval timer | ||
|  |    */ | ||
|  |   private cleanupInterval: NodeJS.Timeout | null = null; | ||
|  |    | ||
|  |   /** | ||
|  |    * Creates a new security handler | ||
|  |    * @param smtpServer - SMTP server instance | ||
|  |    */ | ||
|  |   constructor(smtpServer: ISmtpServer) { | ||
|  |     this.smtpServer = smtpServer; | ||
|  |      | ||
|  |     // Initialize IP reputation checker
 | ||
|  |     this.ipReputationService = new IPReputationChecker(); | ||
|  |      | ||
|  |     // Clean expired denylist entries periodically
 | ||
|  |     this.cleanupInterval = 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<boolean> { | ||
|  |     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; | ||
|  |     } | ||
|  |      | ||
|  |     // Check with IP reputation service
 | ||
|  |     if (!this.ipReputationService) { | ||
|  |       return true; | ||
|  |     } | ||
|  |      | ||
|  |     try { | ||
|  |       // Check with IP reputation service
 | ||
|  |       const reputationResult = await this.ipReputationService.checkReputation(ip); | ||
|  |        | ||
|  |       // Block if score is below HIGH_RISK threshold (20) or if it's spam/proxy/tor/vpn
 | ||
|  |       const isBlocked = reputationResult.score < 20 ||  | ||
|  |                        reputationResult.isSpam ||  | ||
|  |                        reputationResult.isTor ||  | ||
|  |                        reputationResult.isProxy; | ||
|  |        | ||
|  |       if (isBlocked) { | ||
|  |         // Add to local denylist temporarily
 | ||
|  |         const reason = reputationResult.isSpam ? 'spam' :  | ||
|  |                       reputationResult.isTor ? 'tor' :  | ||
|  |                       reputationResult.isProxy ? 'proxy' :  | ||
|  |                       `low reputation score: ${reputationResult.score}`; | ||
|  |         this.addToDenylist(ip, reason, 3600000); // 1 hour
 | ||
|  |          | ||
|  |         // Log the blocked connection
 | ||
|  |         this.logSecurityEvent( | ||
|  |           SecurityEventType.IP_REPUTATION, | ||
|  |           SecurityLogLevel.WARN, | ||
|  |           `Connection blocked by reputation service: ${ip}`, | ||
|  |           {  | ||
|  |             reason, | ||
|  |             score: reputationResult.score, | ||
|  |             isSpam: reputationResult.isSpam, | ||
|  |             isTor: reputationResult.isTor, | ||
|  |             isProxy: reputationResult.isProxy, | ||
|  |             isVPN: reputationResult.isVPN | ||
|  |           } | ||
|  |         ); | ||
|  |          | ||
|  |         return false; | ||
|  |       } | ||
|  |        | ||
|  |       // Log the allowed connection
 | ||
|  |       this.logSecurityEvent( | ||
|  |         SecurityEventType.IP_REPUTATION, | ||
|  |         SecurityLogLevel.INFO, | ||
|  |         `IP reputation check passed: ${ip}`, | ||
|  |         {  | ||
|  |           score: reputationResult.score, | ||
|  |           country: reputationResult.country, | ||
|  |           org: reputationResult.org | ||
|  |         } | ||
|  |       ); | ||
|  |        | ||
|  |       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 auth - Authentication credentials | ||
|  |    * @returns Promise that resolves to true if authenticated | ||
|  |    */ | ||
|  |   public async authenticate(auth: ISmtpAuth): Promise<boolean> { | ||
|  |     const { username, password } = auth; | ||
|  |     // Get auth options from server
 | ||
|  |     const options = this.smtpServer.getOptions(); | ||
|  |     const authOptions = options.auth; | ||
|  |      | ||
|  |     // Check if authentication is enabled
 | ||
|  |     if (!authOptions) { | ||
|  |       this.logSecurityEvent( | ||
|  |         SecurityEventType.AUTHENTICATION, | ||
|  |         SecurityLogLevel.WARN, | ||
|  |         'Authentication attempt when auth is disabled', | ||
|  |         { username } | ||
|  |       ); | ||
|  |        | ||
|  |       return false; | ||
|  |     } | ||
|  |      | ||
|  |     // Note: Method validation and TLS requirement checks would need to be done
 | ||
|  |     // at the caller level since the interface doesn't include session/method info
 | ||
|  |      | ||
|  |     try { | ||
|  |       let authenticated = false; | ||
|  |        | ||
|  |       // Use custom validation function if provided
 | ||
|  |       if ((authOptions as any).validateUser) { | ||
|  |         authenticated = await (authOptions as any).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 } | ||
|  |       ); | ||
|  |        | ||
|  |       return authenticated; | ||
|  |     } catch (error) { | ||
|  |       // Log authentication error
 | ||
|  |       this.logSecurityEvent( | ||
|  |         SecurityEventType.AUTHENTICATION, | ||
|  |         SecurityLogLevel.ERROR, | ||
|  |         `Authentication error: ${error instanceof Error ? error.message : String(error)}`, | ||
|  |         { username, 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<string, any>): 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 } | ||
|  |       ); | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Clean up resources | ||
|  |    */ | ||
|  |   public destroy(): void { | ||
|  |     // Clear the cleanup interval
 | ||
|  |     if (this.cleanupInterval) { | ||
|  |       clearInterval(this.cleanupInterval); | ||
|  |       this.cleanupInterval = null; | ||
|  |     } | ||
|  |      | ||
|  |     // Clear the denylist
 | ||
|  |     this.ipDenylist = []; | ||
|  |      | ||
|  |     // Clean up IP reputation service if it has a destroy method
 | ||
|  |     if (this.ipReputationService && typeof (this.ipReputationService as any).destroy === 'function') { | ||
|  |       (this.ipReputationService as any).destroy(); | ||
|  |     } | ||
|  |      | ||
|  |     SmtpLogger.debug('SecurityHandler destroyed'); | ||
|  |   } | ||
|  | } |