initial
This commit is contained in:
		
							
								
								
									
										345
									
								
								ts/mail/delivery/smtpserver/security-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										345
									
								
								ts/mail/delivery/smtpserver/security-handler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,345 @@ | ||||
| /** | ||||
|  * 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'); | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user