606 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			606 lines
		
	
	
		
			16 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';
 | |
| 
 | |
| /**
 | |
|  * SPF result qualifiers
 | |
|  */
 | |
| export enum SpfQualifier {
 | |
|   PASS = '+',
 | |
|   NEUTRAL = '?',
 | |
|   SOFTFAIL = '~',
 | |
|   FAIL = '-'
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * SPF mechanism types
 | |
|  */
 | |
| export enum SpfMechanismType {
 | |
|   ALL = 'all',
 | |
|   INCLUDE = 'include',
 | |
|   A = 'a',
 | |
|   MX = 'mx',
 | |
|   IP4 = 'ip4',
 | |
|   IP6 = 'ip6',
 | |
|   EXISTS = 'exists',
 | |
|   REDIRECT = 'redirect',
 | |
|   EXP = 'exp'
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * SPF mechanism definition
 | |
|  */
 | |
| export interface SpfMechanism {
 | |
|   qualifier: SpfQualifier;
 | |
|   type: SpfMechanismType;
 | |
|   value?: string;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * SPF record parsed data
 | |
|  */
 | |
| export interface SpfRecord {
 | |
|   version: string;
 | |
|   mechanisms: SpfMechanism[];
 | |
|   modifiers: Record<string, string>;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * SPF verification result
 | |
|  */
 | |
| export interface SpfResult {
 | |
|   result: 'pass' | 'neutral' | 'softfail' | 'fail' | 'temperror' | 'permerror' | 'none';
 | |
|   explanation?: string;
 | |
|   domain: string;
 | |
|   ip: string;
 | |
|   record?: string;
 | |
|   error?: string;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Maximum lookup limit for SPF records (prevent infinite loops)
 | |
|  */
 | |
| const MAX_SPF_LOOKUPS = 10;
 | |
| 
 | |
| /**
 | |
|  * Class for verifying SPF records
 | |
|  */
 | |
| export class SpfVerifier {
 | |
|   // DNS Manager reference for verifying records
 | |
|   private dnsManager?: any;
 | |
|   private lookupCount: number = 0;
 | |
|   
 | |
|   constructor(dnsManager?: any) {
 | |
|     this.dnsManager = dnsManager;
 | |
|   }
 | |
|   
 | |
|   /**
 | |
|    * Parse SPF record from TXT record
 | |
|    * @param record SPF TXT record
 | |
|    * @returns Parsed SPF record or null if invalid
 | |
|    */
 | |
|   public parseSpfRecord(record: string): SpfRecord | null {
 | |
|     if (!record.startsWith('v=spf1')) {
 | |
|       return null;
 | |
|     }
 | |
|     
 | |
|     try {
 | |
|       const spfRecord: SpfRecord = {
 | |
|         version: 'spf1',
 | |
|         mechanisms: [],
 | |
|         modifiers: {}
 | |
|       };
 | |
|       
 | |
|       // Split into terms
 | |
|       const terms = record.split(' ').filter(term => term.length > 0);
 | |
|       
 | |
|       // Skip version term
 | |
|       for (let i = 1; i < terms.length; i++) {
 | |
|         const term = terms[i];
 | |
|         
 | |
|         // Check if it's a modifier (name=value)
 | |
|         if (term.includes('=')) {
 | |
|           const [name, value] = term.split('=');
 | |
|           spfRecord.modifiers[name] = value;
 | |
|           continue;
 | |
|         }
 | |
|         
 | |
|         // Parse as mechanism
 | |
|         let qualifier = SpfQualifier.PASS; // Default is +
 | |
|         let mechanismText = term;
 | |
|         
 | |
|         // Check for qualifier
 | |
|         if (term.startsWith('+') || term.startsWith('-') || 
 | |
|             term.startsWith('~') || term.startsWith('?')) {
 | |
|           qualifier = term[0] as SpfQualifier;
 | |
|           mechanismText = term.substring(1);
 | |
|         }
 | |
|         
 | |
|         // Parse mechanism type and value
 | |
|         const colonIndex = mechanismText.indexOf(':');
 | |
|         let type: SpfMechanismType;
 | |
|         let value: string | undefined;
 | |
|         
 | |
|         if (colonIndex !== -1) {
 | |
|           type = mechanismText.substring(0, colonIndex) as SpfMechanismType;
 | |
|           value = mechanismText.substring(colonIndex + 1);
 | |
|         } else {
 | |
|           type = mechanismText as SpfMechanismType;
 | |
|         }
 | |
|         
 | |
|         spfRecord.mechanisms.push({ qualifier, type, value });
 | |
|       }
 | |
|       
 | |
|       return spfRecord;
 | |
|     } catch (error) {
 | |
|       logger.log('error', `Error parsing SPF record: ${error.message}`, {
 | |
|         record,
 | |
|         error: error.message
 | |
|       });
 | |
|       return null;
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   /**
 | |
|    * Check if IP is in CIDR range
 | |
|    * @param ip IP address to check
 | |
|    * @param cidr CIDR range
 | |
|    * @returns Whether the IP is in the CIDR range
 | |
|    */
 | |
|   private isIpInCidr(ip: string, cidr: string): boolean {
 | |
|     try {
 | |
|       const ipAddress = plugins.ip.Address4.parse(ip);
 | |
|       return ipAddress.isInSubnet(new plugins.ip.Address4(cidr));
 | |
|     } catch (error) {
 | |
|       // Try IPv6
 | |
|       try {
 | |
|         const ipAddress = plugins.ip.Address6.parse(ip);
 | |
|         return ipAddress.isInSubnet(new plugins.ip.Address6(cidr));
 | |
|       } catch (e) {
 | |
|         return false;
 | |
|       }
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   /**
 | |
|    * Check if a domain has the specified IP in its A or AAAA records
 | |
|    * @param domain Domain to check
 | |
|    * @param ip IP address to check
 | |
|    * @returns Whether the domain resolves to the IP
 | |
|    */
 | |
|   private async isDomainResolvingToIp(domain: string, ip: string): Promise<boolean> {
 | |
|     try {
 | |
|       // First try IPv4
 | |
|       const ipv4Addresses = await plugins.dns.promises.resolve4(domain);
 | |
|       if (ipv4Addresses.includes(ip)) {
 | |
|         return true;
 | |
|       }
 | |
|       
 | |
|       // Then try IPv6
 | |
|       const ipv6Addresses = await plugins.dns.promises.resolve6(domain);
 | |
|       if (ipv6Addresses.includes(ip)) {
 | |
|         return true;
 | |
|       }
 | |
|       
 | |
|       return false;
 | |
|     } catch (error) {
 | |
|       return false;
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   /**
 | |
|    * Verify SPF for a given email with IP and helo domain
 | |
|    * @param email Email to verify
 | |
|    * @param ip Sender IP address
 | |
|    * @param heloDomain HELO/EHLO domain used by sender
 | |
|    * @returns SPF verification result
 | |
|    */
 | |
|   public async verify(
 | |
|     email: Email,
 | |
|     ip: string,
 | |
|     heloDomain: string
 | |
|   ): Promise<SpfResult> {
 | |
|     const securityLogger = SecurityLogger.getInstance();
 | |
|     
 | |
|     // Reset lookup count
 | |
|     this.lookupCount = 0;
 | |
|     
 | |
|     // Get domain from envelope from (return-path)
 | |
|     const domain = email.getEnvelopeFrom().split('@')[1] || '';
 | |
|     
 | |
|     if (!domain) {
 | |
|       return {
 | |
|         result: 'permerror',
 | |
|         explanation: 'No envelope from domain',
 | |
|         domain: '',
 | |
|         ip
 | |
|       };
 | |
|     }
 | |
|     
 | |
|     try {
 | |
|       // Look up SPF record
 | |
|       const spfVerificationResult = this.dnsManager ? 
 | |
|         await this.dnsManager.verifySpfRecord(domain) :
 | |
|         { found: false, valid: false, error: 'DNS Manager not available' };
 | |
|       
 | |
|       if (!spfVerificationResult.found) {
 | |
|         return {
 | |
|           result: 'none',
 | |
|           explanation: 'No SPF record found',
 | |
|           domain,
 | |
|           ip
 | |
|         };
 | |
|       }
 | |
|       
 | |
|       if (!spfVerificationResult.valid) {
 | |
|         return {
 | |
|           result: 'permerror',
 | |
|           explanation: 'Invalid SPF record',
 | |
|           domain,
 | |
|           ip,
 | |
|           record: spfVerificationResult.value
 | |
|         };
 | |
|       }
 | |
|       
 | |
|       // Parse SPF record
 | |
|       const spfRecord = this.parseSpfRecord(spfVerificationResult.value);
 | |
|       
 | |
|       if (!spfRecord) {
 | |
|         return {
 | |
|           result: 'permerror',
 | |
|           explanation: 'Failed to parse SPF record',
 | |
|           domain,
 | |
|           ip,
 | |
|           record: spfVerificationResult.value
 | |
|         };
 | |
|       }
 | |
|       
 | |
|       // Check SPF record
 | |
|       const result = await this.checkSpfRecord(spfRecord, domain, ip);
 | |
|       
 | |
|       // Log the result
 | |
|       const spfLogLevel = result.result === 'pass' ? 
 | |
|         SecurityLogLevel.INFO : 
 | |
|         (result.result === 'fail' ? SecurityLogLevel.WARN : SecurityLogLevel.INFO);
 | |
|       
 | |
|       securityLogger.logEvent({
 | |
|         level: spfLogLevel,
 | |
|         type: SecurityEventType.SPF,
 | |
|         message: `SPF ${result.result} for ${domain} from IP ${ip}`,
 | |
|         domain,
 | |
|         details: {
 | |
|           ip,
 | |
|           heloDomain,
 | |
|           result: result.result,
 | |
|           explanation: result.explanation,
 | |
|           record: spfVerificationResult.value
 | |
|         },
 | |
|         success: result.result === 'pass'
 | |
|       });
 | |
|       
 | |
|       return {
 | |
|         ...result,
 | |
|         domain,
 | |
|         ip,
 | |
|         record: spfVerificationResult.value
 | |
|       };
 | |
|     } catch (error) {
 | |
|       // Log error
 | |
|       logger.log('error', `SPF verification error: ${error.message}`, {
 | |
|         domain,
 | |
|         ip,
 | |
|         error: error.message
 | |
|       });
 | |
|       
 | |
|       securityLogger.logEvent({
 | |
|         level: SecurityLogLevel.ERROR,
 | |
|         type: SecurityEventType.SPF,
 | |
|         message: `SPF verification error for ${domain}`,
 | |
|         domain,
 | |
|         details: {
 | |
|           ip,
 | |
|           error: error.message
 | |
|         },
 | |
|         success: false
 | |
|       });
 | |
|       
 | |
|       return {
 | |
|         result: 'temperror',
 | |
|         explanation: `Error verifying SPF: ${error.message}`,
 | |
|         domain,
 | |
|         ip,
 | |
|         error: error.message
 | |
|       };
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   /**
 | |
|    * Check SPF record against IP address
 | |
|    * @param spfRecord Parsed SPF record
 | |
|    * @param domain Domain being checked
 | |
|    * @param ip IP address to check
 | |
|    * @returns SPF result
 | |
|    */
 | |
|   private async checkSpfRecord(
 | |
|     spfRecord: SpfRecord,
 | |
|     domain: string,
 | |
|     ip: string
 | |
|   ): Promise<SpfResult> {
 | |
|     // Check for 'redirect' modifier
 | |
|     if (spfRecord.modifiers.redirect) {
 | |
|       this.lookupCount++;
 | |
|       
 | |
|       if (this.lookupCount > MAX_SPF_LOOKUPS) {
 | |
|         return {
 | |
|           result: 'permerror',
 | |
|           explanation: 'Too many DNS lookups',
 | |
|           domain,
 | |
|           ip
 | |
|         };
 | |
|       }
 | |
|       
 | |
|       // Handle redirect
 | |
|       const redirectDomain = spfRecord.modifiers.redirect;
 | |
|       const redirectResult = this.dnsManager ? 
 | |
|         await this.dnsManager.verifySpfRecord(redirectDomain) :
 | |
|         { found: false, valid: false, error: 'DNS Manager not available' };
 | |
|       
 | |
|       if (!redirectResult.found || !redirectResult.valid) {
 | |
|         return {
 | |
|           result: 'permerror',
 | |
|           explanation: `Invalid redirect to ${redirectDomain}`,
 | |
|           domain,
 | |
|           ip
 | |
|         };
 | |
|       }
 | |
|       
 | |
|       const redirectRecord = this.parseSpfRecord(redirectResult.value);
 | |
|       
 | |
|       if (!redirectRecord) {
 | |
|         return {
 | |
|           result: 'permerror',
 | |
|           explanation: `Failed to parse redirect record from ${redirectDomain}`,
 | |
|           domain,
 | |
|           ip
 | |
|         };
 | |
|       }
 | |
|       
 | |
|       return this.checkSpfRecord(redirectRecord, redirectDomain, ip);
 | |
|     }
 | |
|     
 | |
|     // Check each mechanism in order
 | |
|     for (const mechanism of spfRecord.mechanisms) {
 | |
|       let matched = false;
 | |
|       
 | |
|       switch (mechanism.type) {
 | |
|         case SpfMechanismType.ALL:
 | |
|           matched = true;
 | |
|           break;
 | |
|           
 | |
|         case SpfMechanismType.IP4:
 | |
|           if (mechanism.value) {
 | |
|             matched = this.isIpInCidr(ip, mechanism.value);
 | |
|           }
 | |
|           break;
 | |
|           
 | |
|         case SpfMechanismType.IP6:
 | |
|           if (mechanism.value) {
 | |
|             matched = this.isIpInCidr(ip, mechanism.value);
 | |
|           }
 | |
|           break;
 | |
|           
 | |
|         case SpfMechanismType.A:
 | |
|           this.lookupCount++;
 | |
|           
 | |
|           if (this.lookupCount > MAX_SPF_LOOKUPS) {
 | |
|             return {
 | |
|               result: 'permerror',
 | |
|               explanation: 'Too many DNS lookups',
 | |
|               domain,
 | |
|               ip
 | |
|             };
 | |
|           }
 | |
|           
 | |
|           // Check if domain has A/AAAA record matching IP
 | |
|           const checkDomain = mechanism.value || domain;
 | |
|           matched = await this.isDomainResolvingToIp(checkDomain, ip);
 | |
|           break;
 | |
|           
 | |
|         case SpfMechanismType.MX:
 | |
|           this.lookupCount++;
 | |
|           
 | |
|           if (this.lookupCount > MAX_SPF_LOOKUPS) {
 | |
|             return {
 | |
|               result: 'permerror',
 | |
|               explanation: 'Too many DNS lookups',
 | |
|               domain,
 | |
|               ip
 | |
|             };
 | |
|           }
 | |
|           
 | |
|           // Check MX records
 | |
|           const mxDomain = mechanism.value || domain;
 | |
|           
 | |
|           try {
 | |
|             const mxRecords = await plugins.dns.promises.resolveMx(mxDomain);
 | |
|             
 | |
|             for (const mx of mxRecords) {
 | |
|               // Check if this MX record's IP matches
 | |
|               const mxMatches = await this.isDomainResolvingToIp(mx.exchange, ip);
 | |
|               
 | |
|               if (mxMatches) {
 | |
|                 matched = true;
 | |
|                 break;
 | |
|               }
 | |
|             }
 | |
|           } catch (error) {
 | |
|             // No MX records or error
 | |
|             matched = false;
 | |
|           }
 | |
|           break;
 | |
|           
 | |
|         case SpfMechanismType.INCLUDE:
 | |
|           if (!mechanism.value) {
 | |
|             continue;
 | |
|           }
 | |
|           
 | |
|           this.lookupCount++;
 | |
|           
 | |
|           if (this.lookupCount > MAX_SPF_LOOKUPS) {
 | |
|             return {
 | |
|               result: 'permerror',
 | |
|               explanation: 'Too many DNS lookups',
 | |
|               domain,
 | |
|               ip
 | |
|             };
 | |
|           }
 | |
|           
 | |
|           // Check included domain's SPF record
 | |
|           const includeDomain = mechanism.value;
 | |
|           const includeResult = this.dnsManager ? 
 | |
|             await this.dnsManager.verifySpfRecord(includeDomain) :
 | |
|             { found: false, valid: false, error: 'DNS Manager not available' };
 | |
|           
 | |
|           if (!includeResult.found || !includeResult.valid) {
 | |
|             continue; // Skip this mechanism
 | |
|           }
 | |
|           
 | |
|           const includeRecord = this.parseSpfRecord(includeResult.value);
 | |
|           
 | |
|           if (!includeRecord) {
 | |
|             continue; // Skip this mechanism
 | |
|           }
 | |
|           
 | |
|           // Recursively check the included SPF record
 | |
|           const includeCheck = await this.checkSpfRecord(includeRecord, includeDomain, ip);
 | |
|           
 | |
|           // Include mechanism matches if the result is "pass"
 | |
|           matched = includeCheck.result === 'pass';
 | |
|           break;
 | |
|           
 | |
|         case SpfMechanismType.EXISTS:
 | |
|           if (!mechanism.value) {
 | |
|             continue;
 | |
|           }
 | |
|           
 | |
|           this.lookupCount++;
 | |
|           
 | |
|           if (this.lookupCount > MAX_SPF_LOOKUPS) {
 | |
|             return {
 | |
|               result: 'permerror',
 | |
|               explanation: 'Too many DNS lookups',
 | |
|               domain,
 | |
|               ip
 | |
|             };
 | |
|           }
 | |
|           
 | |
|           // Check if domain exists (has any A record)
 | |
|           try {
 | |
|             await plugins.dns.promises.resolve(mechanism.value, 'A');
 | |
|             matched = true;
 | |
|           } catch (error) {
 | |
|             matched = false;
 | |
|           }
 | |
|           break;
 | |
|       }
 | |
|       
 | |
|       // If this mechanism matched, return its result
 | |
|       if (matched) {
 | |
|         switch (mechanism.qualifier) {
 | |
|           case SpfQualifier.PASS:
 | |
|             return {
 | |
|               result: 'pass',
 | |
|               explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
 | |
|               domain,
 | |
|               ip
 | |
|             };
 | |
|           case SpfQualifier.FAIL:
 | |
|             return {
 | |
|               result: 'fail',
 | |
|               explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
 | |
|               domain,
 | |
|               ip
 | |
|             };
 | |
|           case SpfQualifier.SOFTFAIL:
 | |
|             return {
 | |
|               result: 'softfail',
 | |
|               explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
 | |
|               domain,
 | |
|               ip
 | |
|             };
 | |
|           case SpfQualifier.NEUTRAL:
 | |
|             return {
 | |
|               result: 'neutral',
 | |
|               explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
 | |
|               domain,
 | |
|               ip
 | |
|             };
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|     
 | |
|     // If no mechanism matched, default to neutral
 | |
|     return {
 | |
|       result: 'neutral',
 | |
|       explanation: 'No matching mechanism found',
 | |
|       domain,
 | |
|       ip
 | |
|     };
 | |
|   }
 | |
|   
 | |
|   /**
 | |
|    * Check if email passes SPF verification
 | |
|    * @param email Email to verify
 | |
|    * @param ip Sender IP address
 | |
|    * @param heloDomain HELO/EHLO domain used by sender
 | |
|    * @returns Whether email passes SPF
 | |
|    */
 | |
|   public async verifyAndApply(
 | |
|     email: Email,
 | |
|     ip: string,
 | |
|     heloDomain: string
 | |
|   ): Promise<boolean> {
 | |
|     const result = await this.verify(email, ip, heloDomain);
 | |
|     
 | |
|     // Add headers
 | |
|     email.headers['Received-SPF'] = `${result.result} (${result.domain}: ${result.explanation}) client-ip=${ip}; envelope-from=${email.getEnvelopeFrom()}; helo=${heloDomain};`;
 | |
|     
 | |
|     // Apply policy based on result
 | |
|     switch (result.result) {
 | |
|       case 'fail':
 | |
|         // Fail - mark as spam
 | |
|         email.mightBeSpam = true;
 | |
|         logger.log('warn', `SPF failed for ${result.domain} from ${ip}: ${result.explanation}`);
 | |
|         return false;
 | |
|         
 | |
|       case 'softfail':
 | |
|         // Soft fail - accept but mark as suspicious
 | |
|         email.mightBeSpam = true;
 | |
|         logger.log('info', `SPF softfailed for ${result.domain} from ${ip}: ${result.explanation}`);
 | |
|         return true;
 | |
|         
 | |
|       case 'neutral':
 | |
|       case 'none':
 | |
|         // Neutral or none - accept but note in headers
 | |
|         logger.log('info', `SPF ${result.result} for ${result.domain} from ${ip}: ${result.explanation}`);
 | |
|         return true;
 | |
|         
 | |
|       case 'pass':
 | |
|         // Pass - accept
 | |
|         logger.log('info', `SPF passed for ${result.domain} from ${ip}: ${result.explanation}`);
 | |
|         return true;
 | |
|         
 | |
|       case 'temperror':
 | |
|       case 'permerror':
 | |
|         // Temporary or permanent error - log but accept
 | |
|         logger.log('error', `SPF error for ${result.domain} from ${ip}: ${result.explanation}`);
 | |
|         return true;
 | |
|         
 | |
|       default:
 | |
|         return true;
 | |
|     }
 | |
|   }
 | |
| } |