// This file is part of the happyDeliver (R) project. // Copyright (c) 2025 happyDomain // Authors: Pierre-Olivier Mercier, et al. // // This program is offered under a commercial and under the AGPL license. // For commercial licensing, contact us at . // // For AGPL licensing: // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package analyzer import ( "context" "fmt" "regexp" "strings" "git.happydns.org/happyDeliver/internal/api" ) // checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord { visited := make(map[string]bool) return d.resolveSPFRecords(domain, visited, 0) } // resolveSPFRecords recursively resolves SPF records including include: directives func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int) *[]api.SPFRecord { const maxDepth = 10 // Prevent infinite recursion if depth > maxDepth { return &[]api.SPFRecord{ { Domain: &domain, Valid: false, Error: api.PtrTo("Maximum SPF include depth exceeded"), }, } } // Prevent circular references if visited[domain] { return &[]api.SPFRecord{} } visited[domain] = true ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) defer cancel() txtRecords, err := d.resolver.LookupTXT(ctx, domain) if err != nil { return &[]api.SPFRecord{ { Domain: &domain, Valid: false, Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)), }, } } // Find SPF record (starts with "v=spf1") var spfRecord string spfCount := 0 for _, txt := range txtRecords { if strings.HasPrefix(txt, "v=spf1") { spfRecord = txt spfCount++ } } if spfCount == 0 { return &[]api.SPFRecord{ { Domain: &domain, Valid: false, Error: api.PtrTo("No SPF record found"), }, } } var results []api.SPFRecord if spfCount > 1 { results = append(results, api.SPFRecord{ Domain: &domain, Record: &spfRecord, Valid: false, Error: api.PtrTo("Multiple SPF records found (RFC violation)"), }) return &results } // Basic validation valid := d.validateSPF(spfRecord) // Extract the "all" mechanism qualifier var allQualifier *api.SPFRecordAllQualifier var errMsg *string if !valid { errMsg = api.PtrTo("SPF record appears malformed") } else { // Extract qualifier from the "all" mechanism if strings.HasSuffix(spfRecord, " -all") { allQualifier = api.PtrTo(api.SPFRecordAllQualifier("-")) } else if strings.HasSuffix(spfRecord, " ~all") { allQualifier = api.PtrTo(api.SPFRecordAllQualifier("~")) } else if strings.HasSuffix(spfRecord, " +all") { allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+")) } else if strings.HasSuffix(spfRecord, " ?all") { allQualifier = api.PtrTo(api.SPFRecordAllQualifier("?")) } else if strings.HasSuffix(spfRecord, " all") { // Implicit + qualifier (default) allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+")) } } results = append(results, api.SPFRecord{ Domain: &domain, Record: &spfRecord, Valid: valid, AllQualifier: allQualifier, Error: errMsg, }) // Check for redirect= modifier first (it replaces the entire SPF policy) redirectDomain := d.extractSPFRedirect(spfRecord) if redirectDomain != "" { // redirect= replaces the current domain's policy entirely // Only follow if no other mechanisms matched (per RFC 7208) redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1) if redirectRecords != nil { results = append(results, *redirectRecords...) } return &results } // Extract and resolve include: directives includes := d.extractSPFIncludes(spfRecord) for _, includeDomain := range includes { includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1) if includedRecords != nil { results = append(results, *includedRecords...) } } return &results } // extractSPFIncludes extracts all include: domains from an SPF record func (d *DNSAnalyzer) extractSPFIncludes(record string) []string { var includes []string re := regexp.MustCompile(`include:([^\s]+)`) matches := re.FindAllStringSubmatch(record, -1) for _, match := range matches { if len(match) > 1 { includes = append(includes, match[1]) } } return includes } // extractSPFRedirect extracts the redirect= domain from an SPF record // The redirect= modifier replaces the current domain's SPF policy with that of the target domain func (d *DNSAnalyzer) extractSPFRedirect(record string) string { re := regexp.MustCompile(`redirect=([^\s]+)`) matches := re.FindStringSubmatch(record) if len(matches) > 1 { return matches[1] } return "" } // validateSPF performs basic SPF record validation func (d *DNSAnalyzer) validateSPF(record string) bool { // Must start with v=spf1 if !strings.HasPrefix(record, "v=spf1") { return false } // Check for redirect= modifier (which replaces the need for an 'all' mechanism) if strings.Contains(record, "redirect=") { return true } // Check for common syntax issues // Should have a final mechanism (all, +all, -all, ~all, ?all) validEndings := []string{" all", " +all", " -all", " ~all", " ?all"} hasValidEnding := false for _, ending := range validEndings { if strings.HasSuffix(record, ending) { hasValidEnding = true break } } return hasValidEnding } // hasSPFStrictFail checks if SPF record has strict -all mechanism func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool { return strings.HasSuffix(record, " -all") } func (d *DNSAnalyzer) calculateSPFScore(results *api.DNSResults) (score int) { // SPF is essential for email authentication if results.SpfRecords != nil && len(*results.SpfRecords) > 0 { // Find the main SPF record by skipping redirects // Loop through records to find the last redirect or the first non-redirect mainSPFIndex := 0 for i := 0; i < len(*results.SpfRecords); i++ { spfRecord := (*results.SpfRecords)[i] if spfRecord.Record != nil && strings.Contains(*spfRecord.Record, "redirect=") { // This is a redirect, check if there's a next record if i+1 < len(*results.SpfRecords) { mainSPFIndex = i + 1 } else { // Redirect exists but no target record found break } } else { // Found a non-redirect record mainSPFIndex = i break } } mainSPF := (*results.SpfRecords)[mainSPFIndex] if mainSPF.Valid { // Full points for valid SPF score += 75 // Deduct points based on the all mechanism qualifier if mainSPF.AllQualifier != nil { switch *mainSPF.AllQualifier { case "-": // Strict fail - no deduction, this is the recommended policy score += 25 case "~": // Softfail - moderate penalty case "+", "?": // Pass/neutral - severe penalty score -= 25 } } else { // No 'all' mechanism qualifier extracted - severe penalty score -= 25 } } else if mainSPF.Record != nil { // Partial credit if SPF record exists but has issues score += 25 } } return }