initial
This commit is contained in:
		
							
								
								
									
										606
									
								
								ts/mail/security/classes.spfverifier.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										606
									
								
								ts/mail/security/classes.spfverifier.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,606 @@ | ||||
| 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; | ||||
|     } | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user