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