1// Package spf implements Sender Policy Framework (SPF, RFC 7208) for verifying
2// remote mail server IPs with their published records.
4// With SPF a domain can publish a policy as a DNS TXT record describing which IPs
5// are allowed to send email with SMTP with the domain in the MAIL FROM command,
6// and how to treat SMTP transactions coming from other IPs.
19 "github.com/prometheus/client_golang/prometheus"
20 "github.com/prometheus/client_golang/prometheus/promauto"
22 "github.com/mjl-/mox/dns"
23 "github.com/mjl-/mox/mlog"
24 "github.com/mjl-/mox/smtp"
27// The net package always returns DNS names in absolute, lower-case form. We make
28// sure we make names absolute when looking up. For verifying, we do not want to
29// verify names relative to our local search domain.
31var xlog = mlog.New("spf")
34 metricSPFVerify = promauto.NewHistogramVec(
35 prometheus.HistogramOpts{
36 Name: "mox_spf_verify_duration_seconds",
37 Help: "SPF verify, including lookup, duration and result.",
38 Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20},
46// cross-link rfc and errata
54 ErrName = errors.New("spf: bad domain name")
55 ErrNoRecord = errors.New("spf: no txt record")
56 ErrMultipleRecords = errors.New("spf: multiple spf txt records in dns")
57 ErrDNS = errors.New("spf: lookup of dns record")
58 ErrRecordSyntax = errors.New("spf: malformed spf txt record")
61 ErrTooManyDNSRequests = errors.New("spf: too many dns requests")
62 ErrTooManyVoidLookups = errors.New("spf: too many void lookups")
63 ErrMacroSyntax = errors.New("spf: bad macro syntax")
67 // Maximum number of DNS requests to execute. This excludes some requests, such as
68 // lookups of MX host results.
71 // Maximum number of DNS lookups that result in no records before a StatusPermerror
72 // is returned. This limit aims to prevent abuse.
76// Status is the result of an SPF verification.
83 StatusNone Status = "none" // E.g. no DNS domain name in session, or no SPF record in DNS.
84 StatusNeutral Status = "neutral" // Explicit statement that nothing is said about the IP, "?" qualifier. None and Neutral must be treated the same.
85 StatusPass Status = "pass" // IP is authorized.
86 StatusFail Status = "fail" // IP is exlicitly not authorized. "-" qualifier.
87 StatusSoftfail Status = "softfail" // Weak statement that IP is probably not authorized, "~" qualifier.
88 StatusTemperror Status = "temperror" // Trying again later may succeed, e.g. for temporary DNS lookup error.
89 StatusPermerror Status = "permerror" // Error requiring some intervention to correct. E.g. invalid DNS record.
92// Args are the parameters to the SPF verification algorithm ("check_host" in the RFC).
94// All fields should be set as they can be required for macro expansions.
96 // RemoteIP will be checked as sender for email.
99 // Address from SMTP MAIL FROM command. Zero values for a null reverse path (used for DSNs).
100 MailFromLocalpart smtp.Localpart
101 MailFromDomain dns.Domain
103 // HelloDomain is from the SMTP EHLO/HELO command.
104 HelloDomain dns.IPDomain
107 LocalHostname dns.Domain
109 // Explanation string to use for failure. In case of "include", where explanation
110 // from original domain must be used.
111 // May be set for recursive calls.
114 // Domain to validate.
117 // Effective sender. Equal to MailFrom if non-zero, otherwise set to "postmaster" at HelloDomain.
118 senderLocalpart smtp.Localpart
119 senderDomain dns.Domain
121 // To enforce the limit on lookups. Initialized automatically if nil.
126// Mocked for testing expanding "t" macro.
127var timeNow = time.Now
129// Lookup looks up and parses an SPF TXT record for domain.
130func Lookup(ctx context.Context, resolver dns.Resolver, domain dns.Domain) (rstatus Status, rtxt string, rrecord *Record, rerr error) {
131 log := xlog.WithContext(ctx)
134 log.Debugx("spf lookup result", rerr, mlog.Field("domain", domain), mlog.Field("status", rstatus), mlog.Field("record", rrecord), mlog.Field("duration", time.Since(start)))
138 host := domain.ASCII + "."
139 if err := validateDNS(host); err != nil {
140 return StatusNone, "", nil, fmt.Errorf("%w: %s: %s", ErrName, domain, err)
143 // Lookup spf record.
144 txts, err := dns.WithPackage(resolver, "spf").LookupTXT(ctx, host)
145 if dns.IsNotFound(err) {
146 return StatusNone, "", nil, fmt.Errorf("%w for %s", ErrNoRecord, host)
147 } else if err != nil {
148 return StatusTemperror, "", nil, fmt.Errorf("%w: %s: %s", ErrDNS, host, err)
151 // Parse the records. We only handle those that look like spf records.
154 for _, txt := range txts {
156 r, isspf, err := ParseRecord(txt)
160 } else if err != nil {
162 return StatusPermerror, txt, nil, fmt.Errorf("%w: %s", ErrRecordSyntax, err)
166 return StatusPermerror, "", nil, ErrMultipleRecords
173 return StatusNone, "", nil, ErrNoRecord
175 return StatusNone, text, record, nil
178// Verify checks if a remote IP is allowed to send email for a domain.
180// If the SMTP "MAIL FROM" is set, it is used as identity (domain) to verify.
181// Otherwise, the EHLO domain is verified if it is a valid domain.
183// The returned Received.Result status will always be set, regardless of whether an
185// For status Temperror and Permerror, an error is always returned.
186// For Fail, explanation may be set, and should be returned in the SMTP session if
187// it is the reason the message is rejected. The caller should ensure the
188// explanation is valid for use in SMTP, taking line length and ascii-only
189// requirement into account.
191// Verify takes the maximum number of 10 DNS requests into account, and the maximum
192// of 2 lookups resulting in no records ("void lookups").
193func Verify(ctx context.Context, resolver dns.Resolver, args Args) (received Received, domain dns.Domain, explanation string, rerr error) {
194 log := xlog.WithContext(ctx)
197 metricSPFVerify.WithLabelValues(string(received.Result)).Observe(float64(time.Since(start)) / float64(time.Second))
198 log.Debugx("spf verify result", rerr, mlog.Field("domain", args.domain), mlog.Field("ip", args.RemoteIP), mlog.Field("status", received.Result), mlog.Field("explanation", explanation), mlog.Field("duration", time.Since(start)))
201 isHello, ok := prepare(&args)
205 Comment: "no domain, ehlo is an ip literal and mailfrom is empty",
206 ClientIP: args.RemoteIP,
207 EnvelopeFrom: fmt.Sprintf("%s@%s", args.senderLocalpart, args.HelloDomain.IP.String()),
208 Helo: args.HelloDomain,
209 Receiver: args.LocalHostname.ASCII,
211 return received, dns.Domain{}, "", nil
214 status, mechanism, expl, err := checkHost(ctx, resolver, args)
215 comment := fmt.Sprintf("domain %s", args.domain.ASCII)
217 comment += ", from ehlo because mailfrom is empty"
222 ClientIP: args.RemoteIP,
223 EnvelopeFrom: fmt.Sprintf("%s@%s", args.senderLocalpart, args.senderDomain.ASCII), //
../rfc/7208:2090, explicitly "sender", not "mailfrom".
224 Helo: args.HelloDomain,
225 Receiver: args.LocalHostname.ASCII,
226 Mechanism: mechanism,
229 received.Problem = err.Error()
232 received.Identity = "helo"
234 received.Identity = "mailfrom"
236 return received, args.domain, expl, err
239// prepare args, setting fields sender* and domain as required for checkHost.
240func prepare(args *Args) (isHello bool, ok bool) {
241 // If MAIL FROM is set, that identity is used. Otherwise the EHLO identity is used.
242 // MAIL FROM is preferred, because if we accept the message, and we have to send a
243 // DSN, it helps to know it is a verified sender. If we would check an EHLO
244 // identity, and it is different from the MAIL FROM, we may be sending the DSN to
245 // an address with a domain that would not allow sending from the originating IP.
250 args.explanation = nil
251 args.dnsRequests = nil
252 args.voidLookups = nil
253 if args.MailFromDomain.IsZero() {
254 // If there is on EHLO, and it is an IP, there is nothing to SPF-validate.
255 if !args.HelloDomain.IsDomain() {
258 // If we have a mailfrom, we also have a localpart. But for EHLO we won't.
../rfc/7208:810
259 args.senderLocalpart = "postmaster"
260 args.senderDomain = args.HelloDomain.Domain
263 args.senderLocalpart = args.MailFromLocalpart
264 args.senderDomain = args.MailFromDomain
266 args.domain = args.senderDomain
270// lookup spf record, then evaluate args against it.
271func checkHost(ctx context.Context, resolver dns.Resolver, args Args) (rstatus Status, mechanism, rexplanation string, rerr error) {
272 status, _, record, err := Lookup(ctx, resolver, args.domain)
274 return status, "", "", err
277 return evaluate(ctx, record, resolver, args)
280// Evaluate evaluates the IP and names from args against the SPF DNS record for the domain.
281func Evaluate(ctx context.Context, record *Record, resolver dns.Resolver, args Args) (rstatus Status, mechanism, rexplanation string, rerr error) {
282 _, ok := prepare(&args)
284 return StatusNone, "default", "", fmt.Errorf("no domain name to validate")
286 return evaluate(ctx, record, resolver, args)
289// evaluate RemoteIP against domain from args, given record.
290func evaluate(ctx context.Context, record *Record, resolver dns.Resolver, args Args) (rstatus Status, mechanism, rexplanation string, rerr error) {
291 log := xlog.WithContext(ctx)
294 log.Debugx("spf evaluate result", rerr, mlog.Field("dnsrequests", *args.dnsRequests), mlog.Field("voidlookups", *args.voidLookups), mlog.Field("domain", args.domain), mlog.Field("status", rstatus), mlog.Field("mechanism", mechanism), mlog.Field("explanation", rexplanation), mlog.Field("duration", time.Since(start)))
297 resolver = dns.WithPackage(resolver, "spf")
299 if args.dnsRequests == nil {
300 args.dnsRequests = new(int)
301 args.voidLookups = new(int)
304 // To4 returns nil for an IPv6 address. To16 will return an IPv4-to-IPv6-mapped address.
306 remote4 := args.RemoteIP.To4()
308 remote6 = args.RemoteIP.To16()
311 // Check if ip matches remote ip, taking cidr mask into account.
312 checkIP := func(ip net.IP, d Directive) bool {
320 if d.IP4CIDRLen != nil {
323 mask := net.CIDRMask(ones, 32)
324 return ip4.Mask(mask).Equal(remote4.Mask(mask))
332 if d.IP6CIDRLen != nil {
335 mask := net.CIDRMask(ones, 128)
336 return ip6.Mask(mask).Equal(remote6.Mask(mask))
339 // Used for "a" and "mx".
340 checkHostIP := func(domain dns.Domain, d Directive, args *Args) (bool, Status, error) {
341 ips, err := resolver.LookupIP(ctx, "ip", domain.ASCII+".")
342 trackVoidLookup(err, args)
343 // If "not found", we must ignore the error and treat as zero records in answer.
../rfc/7208:1116
344 if err != nil && !dns.IsNotFound(err) {
345 return false, StatusTemperror, err
347 for _, ip := range ips {
349 return true, StatusPass, nil
352 return false, StatusNone, nil
355 for _, d := range record.Directives {
359 case "include", "a", "mx", "ptr", "exists":
360 if err := trackLookupLimits(&args); err != nil {
361 return StatusPermerror, d.MechanismString(), "", err
372 name, err := expandDomainSpecDNS(ctx, resolver, d.DomainSpec, args)
374 return StatusPermerror, d.MechanismString(), "", fmt.Errorf("expanding domain-spec for include: %w", err)
377 nargs.domain = dns.Domain{ASCII: strings.TrimSuffix(name, ".")}
379 status, _, _, err := checkHost(ctx, resolver, nargs)
384 case StatusTemperror:
385 return StatusTemperror, d.MechanismString(), "", fmt.Errorf("include %q: %w", name, err)
386 case StatusPermerror, StatusNone:
387 return StatusPermerror, d.MechanismString(), "", fmt.Errorf("include %q resulted in status %q: %w", name, status, err)
392 // note: the syntax for DomainSpec hints that macros should be expanded. But
393 // expansion is explicitly documented, and only for "include", "exists" and
394 // "redirect". This reason for this could be low-effort reuse of the domain-spec
395 // ABNF rule. It could be an oversight. We are not implementing expansion for the
396 // mechanism for which it isn't specified.
397 host, err := evaluateDomainSpec(d.DomainSpec, args.domain)
399 return StatusPermerror, d.MechanismString(), "", err
401 hmatch, status, err := checkHostIP(host, d, &args)
403 return status, d.MechanismString(), "", err
409 host, err := evaluateDomainSpec(d.DomainSpec, args.domain)
411 return StatusPermerror, d.MechanismString(), "", err
413 // Note: LookupMX can return an error and still return MX records.
414 mxs, err := resolver.LookupMX(ctx, host.ASCII+".")
415 trackVoidLookup(err, &args)
416 // note: we handle "not found" simply as a result of zero mx records.
417 if err != nil && !dns.IsNotFound(err) {
418 return StatusTemperror, d.MechanismString(), "", err
420 if err == nil && len(mxs) == 1 && mxs[0].Host == "." {
424 for i, mx := range mxs {
426 // requests. This seems independent of the overall limit of 10 DNS requests. So an
427 // MX request resulting in 11 names is valid, but we must return a permerror if we
428 // found no match before the 11th name.
431 return StatusPermerror, d.MechanismString(), "", ErrTooManyDNSRequests
433 mxd, err := dns.ParseDomain(strings.TrimSuffix(mx.Host, "."))
435 return StatusPermerror, d.MechanismString(), "", err
437 hmatch, status, err := checkHostIP(mxd, d, &args)
439 return status, d.MechanismString(), "", err
449 host, err := evaluateDomainSpec(d.DomainSpec, args.domain)
451 return StatusPermerror, d.MechanismString(), "", err
454 rnames, err := resolver.LookupAddr(ctx, args.RemoteIP.String())
455 trackVoidLookup(err, &args)
456 if err != nil && !dns.IsNotFound(err) {
457 return StatusTemperror, d.MechanismString(), "", err
461 for _, rname := range rnames {
462 rd, err := dns.ParseDomain(strings.TrimSuffix(rname, "."))
464 log.Errorx("bad address in ptr record", err, mlog.Field("address", rname))
468 if rd.ASCII != host.ASCII && !strings.HasSuffix(rd.ASCII, "."+host.ASCII) {
477 ips, err := resolver.LookupIP(ctx, "ip", rd.ASCII+".")
478 trackVoidLookup(err, &args)
479 for _, ip := range ips {
490 match = checkIP(d.IP, d)
494 match = checkIP(d.IP, d)
499 name, err := expandDomainSpecDNS(ctx, resolver, d.DomainSpec, args)
501 return StatusPermerror, d.MechanismString(), "", fmt.Errorf("expanding domain-spec for exists: %w", err)
504 ips, err := resolver.LookupIP(ctx, "ip4", ensureAbsDNS(name))
505 // Note: we do count this for void lookups, as that is an anti-abuse mechanism.
507 trackVoidLookup(err, &args)
508 if err != nil && !dns.IsNotFound(err) {
509 return StatusTemperror, d.MechanismString(), "", err
514 return StatusNone, d.MechanismString(), "", fmt.Errorf("internal error, unexpected mechanism %q", d.Mechanism)
522 return StatusPass, d.MechanismString(), "", nil
524 return StatusNeutral, d.MechanismString(), "", nil
528 expl := explanation(ctx, resolver, record, nargs)
529 return StatusFail, d.MechanismString(), expl, nil
531 return StatusSoftfail, d.MechanismString(), "", nil
533 return StatusNone, d.MechanismString(), "", fmt.Errorf("internal error, unexpected qualifier %q", d.Qualifier)
536 if record.Redirect != "" {
540 name, err := expandDomainSpecDNS(ctx, resolver, record.Redirect, args)
542 return StatusPermerror, "", "", fmt.Errorf("expanding domain-spec: %w", err)
545 nargs.domain = dns.Domain{ASCII: strings.TrimSuffix(name, ".")}
547 status, mechanism, expl, err := checkHost(ctx, resolver, nargs)
548 if status == StatusNone {
549 return StatusPermerror, mechanism, "", err
551 return status, mechanism, expl, err
555 return StatusNeutral, "default", "", nil
558// evaluateDomainSpec returns the parsed dns domain for spec if non-empty, and
559// otherwise returns d, which must be the Domain in checkHost Args.
560func evaluateDomainSpec(spec string, d dns.Domain) (dns.Domain, error) {
565 d, err := dns.ParseDomain(spec)
567 return d, fmt.Errorf("%w: %s", ErrName, err)
572func expandDomainSpecDNS(ctx context.Context, resolver dns.Resolver, domainSpec string, args Args) (string, error) {
573 return expandDomainSpec(ctx, resolver, domainSpec, args, true)
576func expandDomainSpecExp(ctx context.Context, resolver dns.Resolver, domainSpec string, args Args) (string, error) {
577 return expandDomainSpec(ctx, resolver, domainSpec, args, false)
580// expandDomainSpec interprets macros in domainSpec.
581// The expansion can fail due to macro syntax errors or DNS errors.
585func expandDomainSpec(ctx context.Context, resolver dns.Resolver, domainSpec string, args Args, dns bool) (string, error) {
590 b := &strings.Builder{}
602 return "", fmt.Errorf("%w: trailing bare %%", ErrMacroSyntax)
616 return "", fmt.Errorf("%w: invalid macro opening %%%c", ErrMacroSyntax, c)
620 return "", fmt.Errorf("%w: missing macro ending }", ErrMacroSyntax)
626 if c >= 'A' && c <= 'Z' {
634 // todo: should we check for utf8 in localpart, and fail? we may now generate utf8 strings to places that may not be able to parse them. it will probably lead to relatively harmless error somewhere else. perhaps we can just transform the localpart to IDN? because it may be used in a dns lookup.
../rfc/7208:1507
635 v = smtp.NewAddress(args.senderLocalpart, args.senderDomain).String()
637 // todo: same about utf8 as for 's'.
638 v = string(args.senderLocalpart)
640 v = args.senderDomain.ASCII
642 v = args.domain.ASCII
644 v = expandIP(args.RemoteIP)
647 if err := trackLookupLimits(&args); err != nil {
650 names, err := resolver.LookupAddr(ctx, args.RemoteIP.String())
651 trackVoidLookup(err, &args)
652 if len(names) == 0 || err != nil {
658 // Verify finds the first dns name that resolves to the remote ip.
659 verify := func(matchfn func(string) bool) (string, error) {
660 for _, name := range names {
664 ips, err := resolver.LookupIP(ctx, "ip", name)
665 trackVoidLookup(err, &args)
667 for _, ip := range ips {
668 if ip.Equal(args.RemoteIP) {
676 // First exact domain name matches, then subdomains, finally other names.
677 domain := args.domain.ASCII + "."
678 dotdomain := "." + domain
679 v, err = verify(func(name string) bool { return name == domain })
684 v, err = verify(func(name string) bool { return strings.HasSuffix(name, dotdomain) })
690 v, err = verify(func(name string) bool { return name != domain && !strings.HasSuffix(name, dotdomain) })
701 if args.RemoteIP.To4() != nil {
707 if args.HelloDomain.IsIP() {
708 //
../rfc/7208:1621 explicitly says "domain", not "ip". We'll handle IP, probably does no harm.
709 v = expandIP(args.HelloDomain.IP)
711 v = args.HelloDomain.Domain.ASCII
715 return "", fmt.Errorf("%w: macro letter %c only allowed in exp", ErrMacroSyntax, c)
719 v = args.LocalIP.String()
721 v = args.LocalHostname.ASCII
723 v = fmt.Sprintf("%d", timeNow().Unix())
726 return "", fmt.Errorf("%w: unknown macro letter %c", ErrMacroSyntax, c)
730 for i < n && s[i] >= '0' && s[i] <= '9' {
731 digits += string(s[i])
736 v, err := strconv.Atoi(digits)
738 return "", fmt.Errorf("%w: bad macro transformer digits %q: %s", ErrMacroSyntax, digits, err)
742 return "", fmt.Errorf("%w: zero labels for digits transformer", ErrMacroSyntax)
746 // If "r" follows, we must reverse the resulting name, splitting on a dot by default.
749 if i < n && (s[i] == 'r' || s[i] == 'R') {
754 // Delimiters to split on, for subset of labels and/or reversing.
758 case '.', '-', '+', ',', '/', '_', '=':
759 delim += string(s[i])
766 if i >= n || s[i] != '}' {
767 return "", fmt.Errorf("%w: missing closing } for macro", ErrMacroSyntax)
771 // Only split and subset and/or reverse if necessary.
772 if nlabels >= 0 || reverse || delim != "" {
781 for i := 0; i < h; i++ {
782 t[i], t[nt-1-i] = t[nt-1-i], t[i]
785 if nlabels > 0 && nlabels < len(t) {
786 t = t[len(t)-nlabels:]
789 v = strings.Join(t, ".")
794 v = url.QueryEscape(v)
801 isAbs := strings.HasSuffix(r, ".")
803 if err := validateDNS(r); err != nil {
804 return "", fmt.Errorf("invalid dns name: %s", err)
806 // If resulting name is too large, cut off labels on the left until it fits.
../rfc/7208:1749
808 labels := strings.Split(r, ".")
809 for i := range labels {
810 if i == len(labels)-1 {
811 return "", fmt.Errorf("expanded dns name too long")
813 s := strings.Join(labels[i+1:], ".")
827func expandIP(ip net.IP) string {
833 for i, b := range ip.To16() {
837 v += fmt.Sprintf("%x.%x", b>>4, b&0xf)
842// validateDNS checks if a DNS name is valid. Must not end in dot. This does not
843// check valid host names, e.g. _ is allows in DNS but not in a host name.
844func validateDNS(s string) error {
846 // note: we are not checking for max 253 bytes length, because one of the callers may be chopping off labels to "correct" the name.
847 labels := strings.Split(s, ".")
848 if len(labels) > 128 {
849 return fmt.Errorf("more than 128 labels")
851 for _, label := range labels[:len(labels)-1] {
853 return fmt.Errorf("label longer than 63 bytes")
857 return fmt.Errorf("empty dns label")
863func split(v, delim string) (r []string) {
864 isdelim := func(c rune) bool {
865 for _, d := range delim {
874 for i, c := range v {
876 r = append(r, v[s:i])
884// explanation does a best-effort attempt to fetch an explanation for a StatusFail response.
885// If no explanation could be composed, an empty string is returned.
886func explanation(ctx context.Context, resolver dns.Resolver, r *Record, args Args) string {
889 // If this record is the result of an "include", we have to use the explanation
890 // string of the original domain, not of this domain.
892 expl := r.Explanation
893 if args.explanation != nil {
894 expl = *args.explanation
902 // Limits for dns requests and void lookups should not be taken into account.
903 // Starting with zero ensures they aren't triggered.
904 args.dnsRequests = new(int)
905 args.voidLookups = new(int)
906 name, err := expandDomainSpecDNS(ctx, resolver, r.Explanation, args)
907 if err != nil || name == "" {
910 txts, err := resolver.LookupTXT(ctx, ensureAbsDNS(name))
911 if err != nil || len(txts) == 0 {
914 txt := strings.Join(txts, "")
915 s, err := expandDomainSpecExp(ctx, resolver, txt, args)
922func ensureAbsDNS(s string) string {
923 if !strings.HasSuffix(s, ".") {
929func trackLookupLimits(args *Args) error {
931 if *args.dnsRequests >= dnsRequestsMax {
932 return ErrTooManyDNSRequests
935 if *args.voidLookups >= voidLookupsMax {
936 return ErrTooManyVoidLookups
942func trackVoidLookup(err error, args *Args) {
943 if dns.IsNotFound(err) {