201 lines
5.4 KiB
Go
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"
|
|
}
|