Files
2026-05-21 20:27:58 +00:00

201 lines
5.4 KiB
Go

// 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"
}