Files
Blacktip/deliverability.go

474 lines
15 KiB
Go

package blacktip
import (
"context"
"errors"
"fmt"
"net"
"sort"
"strconv"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/reef/model"
)
// Deliverability checks for each sending domain and source IP: does SPF
// authorize the IP, is there a DMARC policy worth enforcing, does the domain
// publish MTA-STS, and is the IP on a DNS blocklist. Every finding carries a
// hint saying what to change, since the people reading it run a mail server,
// not a DNS course.
// defaultBlocklists are free-for-small-volume DNSBLs whose "listed" answers are reliable.
var defaultBlocklists = []string{"zen.spamhaus.org", "bl.spamcop.net", "b.barracudacentral.org", "psbl.surriel.com"}
func (b *Blacktip) lookupIPs(name string) ([]net.IP, error) {
if b.cfg.MailLookupIP != nil {
return b.cfg.MailLookupIP(name)
}
addrs, err := net.LookupIP(name)
return addrs, err
}
func (b *Blacktip) lookupMXHosts(domain string) ([]string, error) {
if b.cfg.MailLookupMX != nil {
return b.cfg.MailLookupMX(domain)
}
mxs, err := net.LookupMX(domain)
var out []string
for _, mx := range mxs {
out = append(out, strings.TrimSuffix(mx.Host, "."))
}
if len(out) > 0 {
err = nil // a partial answer is still an answer
}
return out, err
}
func dnsNotFound(err error) bool {
var de *net.DNSError
return errors.As(err, &de) && de.IsNotFound
}
// ---------- SPF (RFC 7208) ----------
const spfLookupLimit = 10
type spfEval struct {
b *Blacktip
ip net.IP
lookups int
}
var errSPFPerm = errors.New("permerror")
// spfRecord returns the domain's SPF record text, or "" if it has none.
func (b *Blacktip) spfRecord(domain string) (string, error) {
txts, err := b.lookupTXT(domain)
if err != nil && !dnsNotFound(err) && len(txts) == 0 {
return "", err
}
rec := ""
for _, t := range txts {
low := strings.ToLower(strings.TrimSpace(t))
if low == "v=spf1" || strings.HasPrefix(low, "v=spf1 ") {
if rec != "" {
return "", fmt.Errorf("%w: %s publishes more than one SPF record", errSPFPerm, domain)
}
rec = strings.TrimSpace(t)
}
}
return rec, nil
}
// check evaluates domain's record for e.ip and returns the qualifier that
// matched ("pass", "fail", "softfail", "neutral") or "none" with no record.
func (e *spfEval) check(domain string, depth int) (string, error) {
if depth > spfLookupLimit {
return "", fmt.Errorf("%w: SPF includes nest too deeply", errSPFPerm)
}
rec, err := e.b.spfRecord(domain)
if err != nil {
return "", err
}
if rec == "" {
return "none", nil
}
redirect := ""
for _, term := range strings.Fields(rec)[1:] {
low := strings.ToLower(term)
if strings.HasPrefix(low, "redirect=") {
redirect = term[len("redirect="):]
continue
}
if strings.Contains(low, "=") && !strings.ContainsAny(low[:strings.IndexByte(low, '=')], ":/") {
continue // an unknown modifier such as exp=
}
q, mech := "pass", low
switch low[0] {
case '+', '-', '~', '?':
q = map[byte]string{'+': "pass", '-': "fail", '~': "softfail", '?': "neutral"}[low[0]]
mech = low[1:]
}
base, cidr4, cidr6 := mech, -1, -1
if !strings.HasPrefix(mech, "ip4:") && !strings.HasPrefix(mech, "ip6:") {
if i := strings.IndexByte(mech, '/'); i >= 0 { // "a/24", "mx:host/24//64"
var ok bool
if cidr4, cidr6, ok = parsePrefixes(mech[i+1:]); !ok {
return "", fmt.Errorf("%w: bad prefix in %q", errSPFPerm, term)
}
base = mech[:i]
}
}
name, arg, _ := strings.Cut(base, ":")
matched := false
switch name {
case "all":
matched = true
case "ip4", "ip6":
matched = ipInSpec(e.ip, arg)
case "a", "mx", "include", "exists", "ptr":
if name == "ptr" {
continue // discouraged by the RFC and slow: treated as not matching
}
if e.lookups++; e.lookups > spfLookupLimit {
return "", fmt.Errorf("%w: more than %d DNS lookups", errSPFPerm, spfLookupLimit)
}
target := domain
if arg != "" {
target = arg
}
switch name {
case "include":
if arg == "" {
return "", fmt.Errorf("%w: include without a domain", errSPFPerm)
}
res, err := e.check(target, depth+1)
if err != nil {
return "", err
}
if res == "none" {
return "", fmt.Errorf("%w: included domain %s has no SPF record", errSPFPerm, target)
}
matched = res == "pass"
case "a":
matched = e.hostMatches(target, cidr4, cidr6)
case "exists":
ips, _ := e.b.lookupIPs(target)
matched = len(ips) > 0
case "mx":
hosts, _ := e.b.lookupMXHosts(target)
for _, h := range hosts {
if e.lookups++; e.lookups > spfLookupLimit {
return "", fmt.Errorf("%w: more than %d DNS lookups", errSPFPerm, spfLookupLimit)
}
if e.hostMatches(h, cidr4, cidr6) {
matched = true
break
}
}
}
}
if matched {
return q, nil
}
}
if redirect != "" {
if e.lookups++; e.lookups > spfLookupLimit {
return "", fmt.Errorf("%w: more than %d DNS lookups", errSPFPerm, spfLookupLimit)
}
res, err := e.check(redirect, depth+1)
if err != nil {
return "", err
}
if res == "none" {
return "", fmt.Errorf("%w: redirect target %s has no SPF record", errSPFPerm, redirect)
}
return res, nil
}
return "neutral", nil
}
// parsePrefixes reads "24", "24//64" or "/64" (IPv4 and IPv6 prefix lengths, -1 when absent).
func parsePrefixes(s string) (v4, v6 int, ok bool) {
v4, v6 = -1, -1
if strings.HasPrefix(s, "/") && !strings.HasPrefix(s, "//") {
s = "/" + s // "a//64" arrives here as "/64"
}
a, b, has6 := strings.Cut(s, "//")
if a != "" {
n, err := strconv.Atoi(a)
if err != nil || n < 0 || n > 32 {
return 0, 0, false
}
v4 = n
}
if has6 {
n, err := strconv.Atoi(b)
if err != nil || n < 0 || n > 128 {
return 0, 0, false
}
v6 = n
}
return v4, v6, true
}
func (e *spfEval) hostMatches(host string, cidr4, cidr6 int) bool {
ips, _ := e.b.lookupIPs(host)
for _, ip := range ips {
bits := cidr4
if ip.To4() == nil {
bits = cidr6
}
if bits < 0 {
if ip.Equal(e.ip) {
return true
}
continue
}
if _, n, err := net.ParseCIDR(ip.String() + "/" + strconv.Itoa(bits)); err == nil && n.Contains(e.ip) {
return true
}
}
return false
}
func ipInSpec(ip net.IP, spec string) bool {
if strings.Contains(spec, "/") {
_, n, err := net.ParseCIDR(spec)
return err == nil && n.Contains(ip)
}
p := net.ParseIP(spec)
return p != nil && p.Equal(ip)
}
// checkSPF says whether the domain's SPF record authorizes the source IP.
func (b *Blacktip) checkSPF(domain, sourceIP string) model.MailFinding {
ip := net.ParseIP(sourceIP)
if ip == nil {
return model.MailFinding{Status: model.FindingWarn, Detail: "the source address isn't an IP address"}
}
hint := fmt.Sprintf("Publish a TXT record on %s: \"v=spf1 ip4:%s ~all\". If other services also send as %s, add their include: entries to the same record.", domain, sourceIP, domain)
if ip.To4() == nil {
hint = strings.Replace(hint, "ip4:", "ip6:", 1)
}
e := &spfEval{b: b, ip: ip}
res, err := e.check(domain, 0)
switch {
case errors.Is(err, errSPFPerm):
return model.MailFinding{Status: model.FindingFail, Detail: "SPF record is invalid: " + strings.TrimPrefix(err.Error(), "permerror: "),
Hint: "Receivers treat an invalid SPF record as a failure. Fix the record on " + domain + "; SPF allows at most 10 DNS lookups across include:, a, mx and redirect, so flatten some includes into ip4: entries."}
case err != nil:
return model.MailFinding{Status: model.FindingWarn, Detail: "couldn't read the SPF record: " + err.Error()}
case res == "none":
return model.MailFinding{Status: model.FindingNone, Detail: "no SPF record published on " + domain, Hint: hint}
case res == "pass":
rec, _ := b.spfRecord(domain)
if low := strings.ToLower(rec); strings.HasSuffix(low, " +all") || strings.HasSuffix(low, " ?all") || strings.HasSuffix(low, " all") {
return model.MailFinding{Status: model.FindingWarn, Detail: "SPF authorizes " + sourceIP + " but ends in a permissive all, which lets anyone send as " + domain,
Hint: "End the record with ~all (or -all once you're sure every sender is listed)."}
}
return model.MailFinding{Status: model.FindingPass, Detail: "SPF authorizes " + sourceIP}
default:
return model.MailFinding{Status: model.FindingFail, Detail: fmt.Sprintf("SPF for %s does not authorize %s (result: %s)", domain, sourceIP, res),
Hint: fmt.Sprintf("Add ip4:%s to the v=spf1 record on %s, before the final all.", sourceIP, domain)}
}
}
// ---------- DMARC (RFC 7489) ----------
// dmarcRecord finds the DMARC record that applies to domain: its own, else its
// parent's (a cheap stand-in for the organizational domain, which would need
// the public suffix list).
func (b *Blacktip) dmarcRecord(domain string) (rec, at string, err error) {
for d := domain; strings.Count(d, ".") >= 1; {
txts, lerr := b.lookupTXT("_dmarc." + d)
if lerr != nil && !dnsNotFound(lerr) && len(txts) == 0 {
return "", "", lerr
}
for _, t := range txts {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(t)), "v=dmarc1") {
return strings.TrimSpace(t), d, nil
}
}
_, d, _ = strings.Cut(d, ".")
}
return "", "", nil
}
func dmarcTags(rec string) map[string]string {
tags := map[string]string{}
for _, f := range strings.Split(rec, ";") {
if k, v, ok := strings.Cut(strings.TrimSpace(f), "="); ok {
tags[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v)
}
}
return tags
}
func (b *Blacktip) checkDMARC(domain string) model.MailFinding {
rec, at, err := b.dmarcRecord(domain)
if err != nil {
return model.MailFinding{Status: model.FindingWarn, Detail: "couldn't read the DMARC record: " + err.Error()}
}
if rec == "" {
return model.MailFinding{Status: model.FindingNone, Detail: "no DMARC record published for " + domain,
Hint: fmt.Sprintf("Publish a TXT record on _dmarc.%s: \"v=DMARC1; p=none; rua=mailto:dmarc@%s\". Start with p=none, read the reports, then tighten it. Gmail and Yahoo now require a DMARC record from bulk senders.", domain, domain)}
}
t := dmarcTags(rec)
where := ""
if at != domain {
where = " (inherited from " + at + ")"
}
p := strings.ToLower(t["p"])
if sp := strings.ToLower(t["sp"]); at != domain && sp != "" {
p = sp
}
switch {
case p != "none" && p != "quarantine" && p != "reject":
return model.MailFinding{Status: model.FindingFail, Detail: "DMARC record has no valid p= policy" + where,
Hint: "Set p=none, p=quarantine or p=reject in the _dmarc." + at + " record; receivers ignore a record without one."}
case t["rua"] == "":
return model.MailFinding{Status: model.FindingWarn, Detail: "DMARC policy is " + p + " but there is no rua= address, so you'd get no reports" + where,
Hint: "Add rua=mailto:dmarc@" + domain + " to the record, then upload the reports you receive on the Mail page."}
case p == "none":
return model.MailFinding{Status: model.FindingWarn, Detail: "DMARC is in monitoring mode (p=none)" + where,
Hint: "Once the reports show only your own legitimate mail passing, move to p=quarantine and then p=reject."}
}
return model.MailFinding{Status: model.FindingPass, Detail: "DMARC policy is " + p + where}
}
// ---------- MTA-STS for the sending domain ----------
func (b *Blacktip) checkMTASTS(domain string) model.MailFinding {
pol := b.stsFor(domain)
if pol == nil {
return model.MailFinding{Status: model.FindingNone, Detail: "no MTA-STS policy for " + domain,
Hint: "Optional but recommended: publish a TXT record on _mta-sts." + domain + " (\"v=STSv1; id=1\") and serve a policy at https://mta-sts." + domain + "/.well-known/mta-sts.txt so senders must use TLS to reach you."}
}
switch pol.Mode {
case "enforce":
return model.MailFinding{Status: model.FindingPass, Detail: "MTA-STS is enforced"}
case "testing":
return model.MailFinding{Status: model.FindingWarn, Detail: "MTA-STS is in testing mode",
Hint: "Once TLS reports look clean, change mode: testing to mode: enforce in the policy file and bump the id in the DNS record."}
}
return model.MailFinding{Status: model.FindingNone, Detail: "MTA-STS policy is switched off (mode: none)"}
}
// ---------- blocklists ----------
func (b *Blacktip) dnsblQuery(name string) ([]string, error) {
if b.cfg.MailLookupDNSBL != nil {
return b.cfg.MailLookupDNSBL(name)
}
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
addrs, err := net.DefaultResolver.LookupHost(ctx, name)
if dnsNotFound(err) {
return nil, nil
}
return addrs, err
}
// reverseIPv4 turns 192.0.2.1 into 1.2.0.192, the form DNSBLs are queried in.
func reverseIPv4(ip net.IP) string {
v4 := ip.To4()
if v4 == nil {
return ""
}
return fmt.Sprintf("%d.%d.%d.%d", v4[3], v4[2], v4[1], v4[0])
}
// checkBlocklists asks each DNSBL about the IP. listed are zones that list it;
// answered counts the zones that gave a usable answer (listing refusals for
// open resolvers, answered as 127.255.255.x, don't count as either).
func (b *Blacktip) checkBlocklists(sourceIP string) (listed []string, answered int) {
rev := reverseIPv4(net.ParseIP(sourceIP))
if rev == "" {
return nil, 0 // the common blocklists only cover IPv4
}
zones := b.cfg.MailBlocklists
if zones == nil {
zones = defaultBlocklists
}
var mu sync.Mutex
var wg sync.WaitGroup
for _, z := range zones {
wg.Add(1)
go func(z string) {
defer wg.Done()
addrs, err := b.dnsblQuery(rev + "." + z)
if err != nil {
return
}
hit, bad := false, false
for _, a := range addrs {
if strings.HasPrefix(a, "127.255.255.") {
bad = true // "query refused": the zone won't answer from this resolver
} else if strings.HasPrefix(a, "127.") {
hit = true
}
}
mu.Lock()
defer mu.Unlock()
if hit {
listed = append(listed, z)
}
if !bad || hit {
answered++
}
}(z)
}
wg.Wait()
sort.Strings(listed)
return listed, answered
}
// ---------- putting it together ----------
// sendingDomains are the domains the policy has DKIM keys for: the ones it signs mail as.
func sendingDomains(pol model.MailPolicy) []string {
seen := map[string]bool{}
var out []string
for _, k := range pol.DKIM {
d := strings.ToLower(k.Domain)
if d != "" && !seen[d] {
seen[d] = true
out = append(out, d)
}
}
sort.Strings(out)
return out
}
// checkDomains runs the per-domain DNS checks for a policy's sending domains.
func (b *Blacktip) checkDomains(pol model.MailPolicy) map[string]model.DomainCheck {
doms := sendingDomains(pol)
if len(doms) == 0 {
return nil
}
out := map[string]model.DomainCheck{}
var mu sync.Mutex
var wg sync.WaitGroup
for _, d := range doms {
wg.Add(1)
go func(d string) {
defer wg.Done()
c := model.DomainCheck{DMARC: b.checkDMARC(d), MTASTS: b.checkMTASTS(d)}
if pol.Smarthost != "" {
c.SPF = model.MailFinding{Status: model.FindingWarn, Detail: "not checked: mail leaves through the smarthost " + pol.Smarthost,
Hint: "The receiving server sees the smarthost's address, so " + d + "'s SPF record must authorize that provider instead."}
} else {
c.SPF = b.checkSPF(d, pol.SourceIP)
}
mu.Lock()
out[d] = c
mu.Unlock()
}(d)
}
wg.Wait()
return out
}