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);
 | |
|   }
 | |
| } |