478 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			478 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | import * as plugins from '../../plugins.ts'; | ||
|  | import { logger } from '../../logger.ts'; | ||
|  | import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.ts'; | ||
|  | // MtaService reference removed
 | ||
|  | import type { Email } from '../core/classes.email.ts'; | ||
|  | import type { IDnsVerificationResult } from '../routing/classes.dnsmanager.ts'; | ||
|  | 
 | ||
|  | /** | ||
|  |  * DMARC policy types | ||
|  |  */ | ||
|  | export enum DmarcPolicy { | ||
|  |   NONE = 'none', | ||
|  |   QUARANTINE = 'quarantine', | ||
|  |   REJECT = 'reject' | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * DMARC alignment modes | ||
|  |  */ | ||
|  | export enum DmarcAlignment { | ||
|  |   RELAXED = 'r', | ||
|  |   STRICT = 's' | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * DMARC record fields | ||
|  |  */ | ||
|  | export interface DmarcRecord { | ||
|  |   // Required fields
 | ||
|  |   version: string; | ||
|  |   policy: DmarcPolicy; | ||
|  |    | ||
|  |   // Optional fields
 | ||
|  |   subdomainPolicy?: DmarcPolicy; | ||
|  |   pct?: number; | ||
|  |   adkim?: DmarcAlignment; | ||
|  |   aspf?: DmarcAlignment; | ||
|  |   reportInterval?: number; | ||
|  |   failureOptions?: string; | ||
|  |   reportUriAggregate?: string[]; | ||
|  |   reportUriForensic?: string[]; | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * DMARC verification result | ||
|  |  */ | ||
|  | export interface DmarcResult { | ||
|  |   hasDmarc: boolean; | ||
|  |   record?: DmarcRecord; | ||
|  |   spfDomainAligned: boolean; | ||
|  |   dkimDomainAligned: boolean; | ||
|  |   spfPassed: boolean; | ||
|  |   dkimPassed: boolean; | ||
|  |   policyEvaluated: DmarcPolicy; | ||
|  |   actualPolicy: DmarcPolicy; | ||
|  |   appliedPercentage: number; | ||
|  |   action: 'pass' | 'quarantine' | 'reject'; | ||
|  |   details: string; | ||
|  |   error?: string; | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Class for verifying and enforcing DMARC policies | ||
|  |  */ | ||
|  | export class DmarcVerifier { | ||
|  |   // DNS Manager reference for verifying records
 | ||
|  |   private dnsManager?: any; | ||
|  |    | ||
|  |   constructor(dnsManager?: any) { | ||
|  |     this.dnsManager = dnsManager; | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Parse a DMARC record from a TXT record string | ||
|  |    * @param record DMARC TXT record string | ||
|  |    * @returns Parsed DMARC record or null if invalid | ||
|  |    */ | ||
|  |   public parseDmarcRecord(record: string): DmarcRecord | null { | ||
|  |     if (!record.startsWith('v=DMARC1')) { | ||
|  |       return null; | ||
|  |     } | ||
|  |      | ||
|  |     try { | ||
|  |       // Initialize record with default values
 | ||
|  |       const dmarcRecord: DmarcRecord = { | ||
|  |         version: 'DMARC1', | ||
|  |         policy: DmarcPolicy.NONE, | ||
|  |         pct: 100, | ||
|  |         adkim: DmarcAlignment.RELAXED, | ||
|  |         aspf: DmarcAlignment.RELAXED | ||
|  |       }; | ||
|  |        | ||
|  |       // Split the record into tag/value pairs
 | ||
|  |       const parts = record.split(';').map(part => part.trim()); | ||
|  |        | ||
|  |       for (const part of parts) { | ||
|  |         if (!part || !part.includes('=')) continue; | ||
|  |          | ||
|  |         const [tag, value] = part.split('=').map(p => p.trim()); | ||
|  |          | ||
|  |         // Process based on tag
 | ||
|  |         switch (tag.toLowerCase()) { | ||
|  |           case 'v': | ||
|  |             dmarcRecord.version = value; | ||
|  |             break; | ||
|  |           case 'p': | ||
|  |             dmarcRecord.policy = value as DmarcPolicy; | ||
|  |             break; | ||
|  |           case 'sp': | ||
|  |             dmarcRecord.subdomainPolicy = value as DmarcPolicy; | ||
|  |             break; | ||
|  |           case 'pct': | ||
|  |             const pctValue = parseInt(value, 10); | ||
|  |             if (!isNaN(pctValue) && pctValue >= 0 && pctValue <= 100) { | ||
|  |               dmarcRecord.pct = pctValue; | ||
|  |             } | ||
|  |             break; | ||
|  |           case 'adkim': | ||
|  |             dmarcRecord.adkim = value as DmarcAlignment; | ||
|  |             break; | ||
|  |           case 'aspf': | ||
|  |             dmarcRecord.aspf = value as DmarcAlignment; | ||
|  |             break; | ||
|  |           case 'ri': | ||
|  |             const interval = parseInt(value, 10); | ||
|  |             if (!isNaN(interval) && interval > 0) { | ||
|  |               dmarcRecord.reportInterval = interval; | ||
|  |             } | ||
|  |             break; | ||
|  |           case 'fo': | ||
|  |             dmarcRecord.failureOptions = value; | ||
|  |             break; | ||
|  |           case 'rua': | ||
|  |             dmarcRecord.reportUriAggregate = value.split(',').map(uri => { | ||
|  |               if (uri.startsWith('mailto:')) { | ||
|  |                 return uri.substring(7).trim(); | ||
|  |               } | ||
|  |               return uri.trim(); | ||
|  |             }); | ||
|  |             break; | ||
|  |           case 'ruf': | ||
|  |             dmarcRecord.reportUriForensic = value.split(',').map(uri => { | ||
|  |               if (uri.startsWith('mailto:')) { | ||
|  |                 return uri.substring(7).trim(); | ||
|  |               } | ||
|  |               return uri.trim(); | ||
|  |             }); | ||
|  |             break; | ||
|  |         } | ||
|  |       } | ||
|  |        | ||
|  |       // Ensure subdomain policy is set if not explicitly provided
 | ||
|  |       if (!dmarcRecord.subdomainPolicy) { | ||
|  |         dmarcRecord.subdomainPolicy = dmarcRecord.policy; | ||
|  |       } | ||
|  |        | ||
|  |       return dmarcRecord; | ||
|  |     } catch (error) { | ||
|  |       logger.log('error', `Error parsing DMARC record: ${error.message}`, { | ||
|  |         record, | ||
|  |         error: error.message | ||
|  |       }); | ||
|  |       return null; | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Check if domains are aligned according to DMARC policy | ||
|  |    * @param headerDomain Domain from header (From) | ||
|  |    * @param authDomain Domain from authentication (SPF, DKIM) | ||
|  |    * @param alignment Alignment mode | ||
|  |    * @returns Whether the domains are aligned | ||
|  |    */ | ||
|  |   private isDomainAligned( | ||
|  |     headerDomain: string, | ||
|  |     authDomain: string, | ||
|  |     alignment: DmarcAlignment | ||
|  |   ): boolean { | ||
|  |     if (!headerDomain || !authDomain) { | ||
|  |       return false; | ||
|  |     } | ||
|  |      | ||
|  |     // For strict alignment, domains must match exactly
 | ||
|  |     if (alignment === DmarcAlignment.STRICT) { | ||
|  |       return headerDomain.toLowerCase() === authDomain.toLowerCase(); | ||
|  |     } | ||
|  |      | ||
|  |     // For relaxed alignment, the authenticated domain must be a subdomain of the header domain
 | ||
|  |     // or the same as the header domain
 | ||
|  |     const headerParts = headerDomain.toLowerCase().split('.'); | ||
|  |     const authParts = authDomain.toLowerCase().split('.'); | ||
|  |      | ||
|  |     // Ensures we have at least two parts (domain and TLD)
 | ||
|  |     if (headerParts.length < 2 || authParts.length < 2) { | ||
|  |       return false; | ||
|  |     } | ||
|  |      | ||
|  |     // Get organizational domain (last two parts)
 | ||
|  |     const headerOrgDomain = headerParts.slice(-2).join('.'); | ||
|  |     const authOrgDomain = authParts.slice(-2).join('.'); | ||
|  |      | ||
|  |     return headerOrgDomain === authOrgDomain; | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Extract domain from an email address | ||
|  |    * @param email Email address | ||
|  |    * @returns Domain part of the email | ||
|  |    */ | ||
|  |   private getDomainFromEmail(email: string): string { | ||
|  |     if (!email) return ''; | ||
|  |      | ||
|  |     // Handle name + email format: "John Doe <john@example.com>"
 | ||
|  |     const matches = email.match(/<([^>]+)>/); | ||
|  |     const address = matches ? matches[1] : email; | ||
|  |      | ||
|  |     const parts = address.split('@'); | ||
|  |     return parts.length > 1 ? parts[1] : ''; | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Check if DMARC verification should be applied based on percentage | ||
|  |    * @param record DMARC record | ||
|  |    * @returns Whether DMARC verification should be applied | ||
|  |    */ | ||
|  |   private shouldApplyDmarc(record: DmarcRecord): boolean { | ||
|  |     if (record.pct === undefined || record.pct === 100) { | ||
|  |       return true; | ||
|  |     } | ||
|  |      | ||
|  |     // Apply DMARC randomly based on percentage
 | ||
|  |     const random = Math.floor(Math.random() * 100) + 1; | ||
|  |     return random <= record.pct; | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Determine the action to take based on DMARC policy | ||
|  |    * @param policy DMARC policy | ||
|  |    * @returns Action to take | ||
|  |    */ | ||
|  |   private determineAction(policy: DmarcPolicy): 'pass' | 'quarantine' | 'reject' { | ||
|  |     switch (policy) { | ||
|  |       case DmarcPolicy.REJECT: | ||
|  |         return 'reject'; | ||
|  |       case DmarcPolicy.QUARANTINE: | ||
|  |         return 'quarantine'; | ||
|  |       case DmarcPolicy.NONE: | ||
|  |       default: | ||
|  |         return 'pass'; | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Verify DMARC for an incoming email | ||
|  |    * @param email Email to verify | ||
|  |    * @param spfResult SPF verification result | ||
|  |    * @param dkimResult DKIM verification result | ||
|  |    * @returns DMARC verification result | ||
|  |    */ | ||
|  |   public async verify( | ||
|  |     email: Email, | ||
|  |     spfResult: { domain: string; result: boolean }, | ||
|  |     dkimResult: { domain: string; result: boolean } | ||
|  |   ): Promise<DmarcResult> { | ||
|  |     const securityLogger = SecurityLogger.getInstance(); | ||
|  |      | ||
|  |     // Initialize result
 | ||
|  |     const result: DmarcResult = { | ||
|  |       hasDmarc: false, | ||
|  |       spfDomainAligned: false, | ||
|  |       dkimDomainAligned: false, | ||
|  |       spfPassed: spfResult.result, | ||
|  |       dkimPassed: dkimResult.result, | ||
|  |       policyEvaluated: DmarcPolicy.NONE, | ||
|  |       actualPolicy: DmarcPolicy.NONE, | ||
|  |       appliedPercentage: 100, | ||
|  |       action: 'pass', | ||
|  |       details: 'DMARC not configured' | ||
|  |     }; | ||
|  |      | ||
|  |     try { | ||
|  |       // Extract From domain
 | ||
|  |       const fromHeader = email.getFromEmail(); | ||
|  |       const fromDomain = this.getDomainFromEmail(fromHeader); | ||
|  |        | ||
|  |       if (!fromDomain) { | ||
|  |         result.error = 'Invalid From domain'; | ||
|  |         return result; | ||
|  |       } | ||
|  |        | ||
|  |       // Check alignment
 | ||
|  |       result.spfDomainAligned = this.isDomainAligned( | ||
|  |         fromDomain, | ||
|  |         spfResult.domain, | ||
|  |         DmarcAlignment.RELAXED | ||
|  |       ); | ||
|  |        | ||
|  |       result.dkimDomainAligned = this.isDomainAligned( | ||
|  |         fromDomain, | ||
|  |         dkimResult.domain, | ||
|  |         DmarcAlignment.RELAXED | ||
|  |       ); | ||
|  |        | ||
|  |       // Lookup DMARC record
 | ||
|  |       const dmarcVerificationResult = this.dnsManager ?  | ||
|  |         await this.dnsManager.verifyDmarcRecord(fromDomain) : | ||
|  |         { found: false, valid: false, error: 'DNS Manager not available' }; | ||
|  |        | ||
|  |       // If DMARC record exists and is valid
 | ||
|  |       if (dmarcVerificationResult.found && dmarcVerificationResult.valid) { | ||
|  |         result.hasDmarc = true; | ||
|  |          | ||
|  |         // Parse DMARC record
 | ||
|  |         const parsedRecord = this.parseDmarcRecord(dmarcVerificationResult.value); | ||
|  |          | ||
|  |         if (parsedRecord) { | ||
|  |           result.record = parsedRecord; | ||
|  |           result.actualPolicy = parsedRecord.policy; | ||
|  |           result.appliedPercentage = parsedRecord.pct || 100; | ||
|  |            | ||
|  |           // Override alignment modes if specified in record
 | ||
|  |           if (parsedRecord.adkim) { | ||
|  |             result.dkimDomainAligned = this.isDomainAligned( | ||
|  |               fromDomain, | ||
|  |               dkimResult.domain, | ||
|  |               parsedRecord.adkim | ||
|  |             ); | ||
|  |           } | ||
|  |            | ||
|  |           if (parsedRecord.aspf) { | ||
|  |             result.spfDomainAligned = this.isDomainAligned( | ||
|  |               fromDomain, | ||
|  |               spfResult.domain, | ||
|  |               parsedRecord.aspf | ||
|  |             ); | ||
|  |           } | ||
|  |            | ||
|  |           // Determine DMARC compliance
 | ||
|  |           const spfAligned = result.spfPassed && result.spfDomainAligned; | ||
|  |           const dkimAligned = result.dkimPassed && result.dkimDomainAligned; | ||
|  |            | ||
|  |           // Email passes DMARC if either SPF or DKIM passes with alignment
 | ||
|  |           const dmarcPass = spfAligned || dkimAligned; | ||
|  |            | ||
|  |           // Use record percentage to determine if policy should be applied
 | ||
|  |           const applyPolicy = this.shouldApplyDmarc(parsedRecord); | ||
|  |            | ||
|  |           if (!dmarcPass) { | ||
|  |             // DMARC failed, apply policy
 | ||
|  |             result.policyEvaluated = applyPolicy ? parsedRecord.policy : DmarcPolicy.NONE; | ||
|  |             result.action = this.determineAction(result.policyEvaluated); | ||
|  |             result.details = `DMARC failed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}, policy=${result.policyEvaluated}`; | ||
|  |           } else { | ||
|  |             result.policyEvaluated = DmarcPolicy.NONE; | ||
|  |             result.action = 'pass'; | ||
|  |             result.details = `DMARC passed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}`; | ||
|  |           } | ||
|  |         } else { | ||
|  |           result.error = 'Invalid DMARC record format'; | ||
|  |           result.details = 'DMARC record invalid'; | ||
|  |         } | ||
|  |       } else { | ||
|  |         // No DMARC record found or invalid
 | ||
|  |         result.details = dmarcVerificationResult.error || 'No DMARC record found'; | ||
|  |       } | ||
|  |        | ||
|  |       // Log the DMARC verification
 | ||
|  |       securityLogger.logEvent({ | ||
|  |         level: result.action === 'pass' ? SecurityLogLevel.INFO : SecurityLogLevel.WARN, | ||
|  |         type: SecurityEventType.DMARC, | ||
|  |         message: result.details, | ||
|  |         domain: fromDomain, | ||
|  |         details: { | ||
|  |           fromDomain, | ||
|  |           spfDomain: spfResult.domain, | ||
|  |           dkimDomain: dkimResult.domain, | ||
|  |           spfPassed: result.spfPassed, | ||
|  |           dkimPassed: result.dkimPassed, | ||
|  |           spfAligned: result.spfDomainAligned, | ||
|  |           dkimAligned: result.dkimDomainAligned, | ||
|  |           dmarcPolicy: result.policyEvaluated, | ||
|  |           action: result.action | ||
|  |         }, | ||
|  |         success: result.action === 'pass' | ||
|  |       }); | ||
|  |        | ||
|  |       return result; | ||
|  |     } catch (error) { | ||
|  |       logger.log('error', `Error verifying DMARC: ${error.message}`, { | ||
|  |         error: error.message, | ||
|  |         emailId: email.getMessageId() | ||
|  |       }); | ||
|  |        | ||
|  |       result.error = `DMARC verification error: ${error.message}`; | ||
|  |        | ||
|  |       // Log error
 | ||
|  |       securityLogger.logEvent({ | ||
|  |         level: SecurityLogLevel.ERROR, | ||
|  |         type: SecurityEventType.DMARC, | ||
|  |         message: `DMARC verification failed with error`, | ||
|  |         details: { | ||
|  |           error: error.message, | ||
|  |           emailId: email.getMessageId() | ||
|  |         }, | ||
|  |         success: false | ||
|  |       }); | ||
|  |        | ||
|  |       return result; | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Apply DMARC policy to an email | ||
|  |    * @param email Email to apply policy to | ||
|  |    * @param dmarcResult DMARC verification result | ||
|  |    * @returns Whether the email should be accepted | ||
|  |    */ | ||
|  |   public applyPolicy(email: Email, dmarcResult: DmarcResult): boolean { | ||
|  |     // Apply action based on DMARC verification result
 | ||
|  |     switch (dmarcResult.action) { | ||
|  |       case 'reject': | ||
|  |         // Reject the email
 | ||
|  |         email.mightBeSpam = true; | ||
|  |         logger.log('warn', `Email rejected due to DMARC policy: ${dmarcResult.details}`, { | ||
|  |           emailId: email.getMessageId(), | ||
|  |           from: email.getFromEmail(), | ||
|  |           subject: email.subject | ||
|  |         }); | ||
|  |         return false; | ||
|  |          | ||
|  |       case 'quarantine': | ||
|  |         // Quarantine the email (mark as spam)
 | ||
|  |         email.mightBeSpam = true; | ||
|  |          | ||
|  |         // Add spam header
 | ||
|  |         if (!email.headers['X-Spam-Flag']) { | ||
|  |           email.headers['X-Spam-Flag'] = 'YES'; | ||
|  |         } | ||
|  |          | ||
|  |         // Add DMARC reason header
 | ||
|  |         email.headers['X-DMARC-Result'] = dmarcResult.details; | ||
|  |          | ||
|  |         logger.log('warn', `Email quarantined due to DMARC policy: ${dmarcResult.details}`, { | ||
|  |           emailId: email.getMessageId(), | ||
|  |           from: email.getFromEmail(), | ||
|  |           subject: email.subject | ||
|  |         }); | ||
|  |         return true; | ||
|  |          | ||
|  |       case 'pass': | ||
|  |       default: | ||
|  |         // Accept the email
 | ||
|  |         // Add DMARC result header for information
 | ||
|  |         email.headers['X-DMARC-Result'] = dmarcResult.details; | ||
|  |         return true; | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * End-to-end DMARC verification and policy application | ||
|  |    * This method should be called after SPF and DKIM verification | ||
|  |    * @param email Email to verify | ||
|  |    * @param spfResult SPF verification result | ||
|  |    * @param dkimResult DKIM verification result | ||
|  |    * @returns Whether the email should be accepted | ||
|  |    */ | ||
|  |   public async verifyAndApply( | ||
|  |     email: Email, | ||
|  |     spfResult: { domain: string; result: boolean }, | ||
|  |     dkimResult: { domain: string; result: boolean } | ||
|  |   ): Promise<boolean> { | ||
|  |     // Verify DMARC
 | ||
|  |     const dmarcResult = await this.verify(email, spfResult, dkimResult); | ||
|  |      | ||
|  |     // Apply DMARC policy
 | ||
|  |     return this.applyPolicy(email, dmarcResult); | ||
|  |   } | ||
|  | } |