// Package spf implements basic SPF (RFC 7208) DNS lookup and policy evaluation. // Only stdlib net is used for DNS. Lookup limit: 10 DNS mechanisms per spec. package spf import ( "fmt" "net" "strings" ) // Result values per RFC 7208 ยง2.6. type Result int const ( ResultNone Result = iota // no SPF record ResultNeutral // ?all ResultPass // sender is permitted ResultFail // sender is not permitted (hard fail) ResultSoftFail // ~all (likely spam) ResultTempError // transient DNS error ResultPermError // permanent SPF parse error ) func (r Result) String() string { return [...]string{"none", "neutral", "pass", "fail", "softfail", "temperror", "permerror"}[r] } // Check performs SPF evaluation for the given sender IP and envelope-from domain. // Returns the SPF result and an explanation string. func Check(clientIP net.IP, senderDomain string) (Result, string) { if senderDomain == "" { return ResultNone, "empty sender domain" } record, err := fetchSPF(senderDomain) if err != nil { return ResultTempError, fmt.Sprintf("SPF DNS lookup %s: %v", senderDomain, err) } if record == "" { return ResultNone, "no SPF record for " + senderDomain } result, msg := evaluate(clientIP, senderDomain, record, 0) return result, msg } // fetchSPF queries TXT records for the domain and returns the first SPF record. func fetchSPF(domain string) (string, error) { txts, err := net.LookupTXT(domain) if err != nil { // Treat NXDOMAIN as "no record" not an error. if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound { return "", nil } return "", err } for _, txt := range txts { txt = strings.TrimSpace(txt) if strings.HasPrefix(txt, "v=spf1") { return txt, nil } } return "", nil } // evaluate parses and applies SPF mechanisms. dnsLookups tracks the lookup count. func evaluate(ip net.IP, domain, record string, dnsLookups int) (Result, string) { parts := strings.Fields(record) if len(parts) == 0 || !strings.EqualFold(parts[0], "v=spf1") { return ResultPermError, "invalid SPF record: " + record } for _, term := range parts[1:] { if dnsLookups > 10 { return ResultPermError, "exceeded 10 DNS lookups" } // Qualifier prefix: +pass -fail ~softfail ?neutral qualifier := ResultPass switch term[0] { case '+': qualifier = ResultPass term = term[1:] case '-': qualifier = ResultFail term = term[1:] case '~': qualifier = ResultSoftFail term = term[1:] case '?': qualifier = ResultNeutral term = term[1:] } lower := strings.ToLower(term) switch { case lower == "all": return qualifier, "matched 'all'" case strings.HasPrefix(lower, "ip4:"): cidr := term[4:] if !strings.Contains(cidr, "/") { cidr += "/32" } _, network, err := net.ParseCIDR(cidr) if err != nil { continue } if ip.To4() != nil && network.Contains(ip) { return qualifier, fmt.Sprintf("matched ip4:%s", term[4:]) } case strings.HasPrefix(lower, "ip6:"): cidr := term[4:] if !strings.Contains(cidr, "/") { cidr += "/128" } _, network, err := net.ParseCIDR(cidr) if err != nil { continue } if ip.To4() == nil && network.Contains(ip) { return qualifier, fmt.Sprintf("matched ip6:%s", term[4:]) } case lower == "a" || strings.HasPrefix(lower, "a:") || strings.HasPrefix(lower, "a/"): dnsLookups++ checkDomain := domain if strings.HasPrefix(lower, "a:") { checkDomain = term[2:] } addrs, err := net.LookupHost(checkDomain) if err == nil { for _, addr := range addrs { if net.ParseIP(addr).Equal(ip) { return qualifier, fmt.Sprintf("matched a:%s", checkDomain) } } } case lower == "mx" || strings.HasPrefix(lower, "mx:") || strings.HasPrefix(lower, "mx/"): dnsLookups++ checkDomain := domain if strings.HasPrefix(lower, "mx:") { checkDomain = term[3:] } mxs, err := net.LookupMX(checkDomain) if err == nil { for _, mx := range mxs { dnsLookups++ addrs, err := net.LookupHost(mx.Host) if err == nil { for _, addr := range addrs { if net.ParseIP(addr).Equal(ip) { return qualifier, fmt.Sprintf("matched mx:%s", checkDomain) } } } } } case strings.HasPrefix(lower, "include:"): includeDomain := term[8:] dnsLookups++ includeRecord, err := fetchSPF(includeDomain) if err != nil { return ResultTempError, fmt.Sprintf("include %s DNS error: %v", includeDomain, err) } if includeRecord == "" { return ResultPermError, "include domain has no SPF: " + includeDomain } subResult, subMsg := evaluate(ip, includeDomain, includeRecord, dnsLookups) if subResult == ResultPass { return qualifier, fmt.Sprintf("include:%s โ†’ %s", includeDomain, subMsg) } case strings.HasPrefix(lower, "redirect="): redirectDomain := term[9:] dnsLookups++ redirectRecord, err := fetchSPF(redirectDomain) if err != nil { return ResultTempError, fmt.Sprintf("redirect %s DNS error: %v", redirectDomain, err) } if redirectRecord == "" { return ResultNone, "redirect domain has no SPF: " + redirectDomain } return evaluate(ip, redirectDomain, redirectRecord, dnsLookups) case strings.HasPrefix(lower, "exp="): // Explanation modifier โ€” ignore. default: // Unknown mechanism โ€” ignore per spec. } } // No mechanism matched. return ResultNeutral, "no mechanism matched" }