import * as plugins from '../../plugins.js'; import { logger } from '../../logger.js'; import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js'; import type { MtaService } from '../delivery/classes.mta.js'; import type { Email } from '../core/classes.email.js'; import type { IDnsVerificationResult } from '../routing/classes.dnsmanager.js'; /** * 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; } /** * 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 { private mtaRef: MtaService; private lookupCount: number = 0; constructor(mtaRefArg: MtaService) { this.mtaRef = mtaRefArg; } /** * 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 { 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 { 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 = await this.mtaRef.dnsManager.verifySpfRecord(domain); 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 { // 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 = await this.mtaRef.dnsManager.verifySpfRecord(redirectDomain); 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 = await this.mtaRef.dnsManager.verifySpfRecord(includeDomain); 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 { 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; } } }