1// Package spf implements Sender Policy Framework (SPF, RFC 7208) for verifying
2// remote mail server IPs with their published records.
3//
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.
7package spf
8
9import (
10 "context"
11 "errors"
12 "fmt"
13 "log/slog"
14 "net"
15 "net/url"
16 "strconv"
17 "strings"
18 "time"
19
20 "github.com/mjl-/mox/dns"
21 "github.com/mjl-/mox/mlog"
22 "github.com/mjl-/mox/smtp"
23 "github.com/mjl-/mox/stub"
24)
25
26// The net package always returns DNS names in absolute, lower-case form. We make
27// sure we make names absolute when looking up. For verifying, we do not want to
28// verify names relative to our local search domain.
29
30var (
31 MetricVerify stub.HistogramVec = stub.HistogramVecIgnore{}
32)
33
34// cross-link rfc and errata
35// ../rfc/7208-eid5436 ../rfc/7208:2043
36// ../rfc/7208-eid6721 ../rfc/7208:1928
37// ../rfc/7208-eid5227 ../rfc/7208:1297
38// ../rfc/7208-eid6595 ../rfc/7208:984
39
40var (
41 // Lookup errors.
42 ErrName = errors.New("spf: bad domain name")
43 ErrNoRecord = errors.New("spf: no txt record")
44 ErrMultipleRecords = errors.New("spf: multiple spf txt records in dns")
45 ErrDNS = errors.New("spf: lookup of dns record")
46 ErrRecordSyntax = errors.New("spf: malformed spf txt record")
47
48 // Evaluation errors.
49 ErrTooManyDNSRequests = errors.New("spf: too many dns requests")
50 ErrTooManyVoidLookups = errors.New("spf: too many void lookups")
51 ErrMacroSyntax = errors.New("spf: bad macro syntax")
52)
53
54const (
55 // Maximum number of DNS requests to execute. This excludes some requests, such as
56 // lookups of MX host results.
57 dnsRequestsMax = 10
58
59 // Maximum number of DNS lookups that result in no records before a StatusPermerror
60 // is returned. This limit aims to prevent abuse.
61 voidLookupsMax = 2
62)
63
64// Status is the result of an SPF verification.
65type Status string
66
67// ../rfc/7208:517
68// ../rfc/7208:1836
69
70const (
71 StatusNone Status = "none" // E.g. no DNS domain name in session, or no SPF record in DNS.
72 StatusNeutral Status = "neutral" // Explicit statement that nothing is said about the IP, "?" qualifier. None and Neutral must be treated the same.
73 StatusPass Status = "pass" // IP is authorized.
74 StatusFail Status = "fail" // IP is exlicitly not authorized. "-" qualifier.
75 StatusSoftfail Status = "softfail" // Weak statement that IP is probably not authorized, "~" qualifier.
76 StatusTemperror Status = "temperror" // Trying again later may succeed, e.g. for temporary DNS lookup error.
77 StatusPermerror Status = "permerror" // Error requiring some intervention to correct. E.g. invalid DNS record.
78)
79
80// Args are the parameters to the SPF verification algorithm ("check_host" in the RFC).
81//
82// All fields should be set as they can be required for macro expansions.
83type Args struct {
84 // RemoteIP will be checked as sender for email.
85 RemoteIP net.IP
86
87 // Address from SMTP MAIL FROM command. Zero values for a null reverse path (used for DSNs).
88 MailFromLocalpart smtp.Localpart
89 MailFromDomain dns.Domain
90
91 // HelloDomain is from the SMTP EHLO/HELO command.
92 HelloDomain dns.IPDomain
93
94 LocalIP net.IP
95 LocalHostname dns.Domain
96
97 // Explanation string to use for failure. In case of "include", where explanation
98 // from original domain must be used.
99 // May be set for recursive calls.
100 explanation *string
101
102 // Domain to validate.
103 domain dns.Domain
104
105 // Effective sender. Equal to MailFrom if non-zero, otherwise set to "postmaster" at HelloDomain.
106 senderLocalpart smtp.Localpart
107 senderDomain dns.Domain
108
109 // To enforce the limit on lookups. Initialized automatically if nil.
110 dnsRequests *int
111 voidLookups *int
112}
113
114// Mocked for testing expanding "t" macro.
115var timeNow = time.Now
116
117// Lookup looks up and parses an SPF TXT record for domain.
118//
119// Authentic indicates if the DNS results were DNSSEC-verified.
120func Lookup(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, domain dns.Domain) (rstatus Status, rtxt string, rrecord *Record, authentic bool, rerr error) {
121 log := mlog.New("spf", elog)
122 start := time.Now()
123 defer func() {
124 log.Debugx("spf lookup result", rerr,
125 slog.Any("domain", domain),
126 slog.Any("status", rstatus),
127 slog.Any("record", rrecord),
128 slog.Duration("duration", time.Since(start)))
129 }()
130
131 // ../rfc/7208:586
132 host := domain.ASCII + "."
133 if err := validateDNS(host); err != nil {
134 return StatusNone, "", nil, false, fmt.Errorf("%w: %s: %s", ErrName, domain, err)
135 }
136
137 // Lookup spf record.
138 txts, result, err := dns.WithPackage(resolver, "spf").LookupTXT(ctx, host)
139 if dns.IsNotFound(err) {
140 return StatusNone, "", nil, result.Authentic, fmt.Errorf("%w for %s", ErrNoRecord, host)
141 } else if err != nil {
142 return StatusTemperror, "", nil, result.Authentic, fmt.Errorf("%w: %s: %s", ErrDNS, host, err)
143 }
144
145 // Parse the records. We only handle those that look like spf records.
146 var record *Record
147 var text string
148 for _, txt := range txts {
149 var isspf bool
150 r, isspf, err := ParseRecord(txt)
151 if !isspf {
152 // ../rfc/7208:595
153 continue
154 } else if err != nil {
155 // ../rfc/7208:852
156 return StatusPermerror, txt, nil, result.Authentic, fmt.Errorf("%w: %s", ErrRecordSyntax, err)
157 }
158 if record != nil {
159 // ../rfc/7208:576
160 return StatusPermerror, "", nil, result.Authentic, ErrMultipleRecords
161 }
162 text = txt
163 record = r
164 }
165 if record == nil {
166 // ../rfc/7208:837
167 return StatusNone, "", nil, result.Authentic, ErrNoRecord
168 }
169 return StatusNone, text, record, result.Authentic, nil
170}
171
172// Verify checks if a remote IP is allowed to send email for a domain.
173//
174// If the SMTP "MAIL FROM" is set, it is used as identity (domain) to verify.
175// Otherwise, the EHLO domain is verified if it is a valid domain.
176//
177// The returned Received.Result status will always be set, regardless of whether an
178// error is returned.
179// For status Temperror and Permerror, an error is always returned.
180// For Fail, explanation may be set, and should be returned in the SMTP session if
181// it is the reason the message is rejected. The caller should ensure the
182// explanation is valid for use in SMTP, taking line length and ascii-only
183// requirement into account.
184//
185// Verify takes the maximum number of 10 DNS requests into account, and the maximum
186// of 2 lookups resulting in no records ("void lookups").
187//
188// Authentic indicates if the DNS results were DNSSEC-verified.
189func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, args Args) (received Received, domain dns.Domain, explanation string, authentic bool, rerr error) {
190 log := mlog.New("spf", elog)
191 start := time.Now()
192 defer func() {
193 MetricVerify.ObserveLabels(float64(time.Since(start))/float64(time.Second), string(received.Result))
194 log.Debugx("spf verify result", rerr,
195 slog.Any("domain", args.domain),
196 slog.Any("ip", args.RemoteIP),
197 slog.Any("status", received.Result),
198 slog.String("explanation", explanation),
199 slog.Duration("duration", time.Since(start)))
200 }()
201
202 isHello, ok := prepare(&args)
203 if !ok {
204 received = Received{
205 Result: StatusNone,
206 Comment: "no domain, ehlo is an ip literal and mailfrom is empty",
207 ClientIP: args.RemoteIP,
208 EnvelopeFrom: fmt.Sprintf("%s@%s", args.senderLocalpart, args.HelloDomain.IP.String()),
209 Helo: args.HelloDomain,
210 Receiver: args.LocalHostname.ASCII,
211 }
212 return received, dns.Domain{}, "", false, nil
213 }
214
215 status, mechanism, expl, authentic, err := checkHost(ctx, log, resolver, args)
216 comment := fmt.Sprintf("domain %s", args.domain.ASCII)
217 if isHello {
218 comment += ", from ehlo because mailfrom is empty"
219 }
220 received = Received{
221 Result: status,
222 Comment: comment,
223 ClientIP: args.RemoteIP,
224 EnvelopeFrom: fmt.Sprintf("%s@%s", args.senderLocalpart, args.senderDomain.ASCII), // ../rfc/7208:2090, explicitly "sender", not "mailfrom".
225 Helo: args.HelloDomain,
226 Receiver: args.LocalHostname.ASCII,
227 Mechanism: mechanism,
228 }
229 if err != nil {
230 received.Problem = err.Error()
231 }
232 if isHello {
233 received.Identity = "helo"
234 } else {
235 received.Identity = "mailfrom"
236 }
237 return received, args.domain, expl, authentic, err
238}
239
240// prepare args, setting fields sender* and domain as required for checkHost.
241func prepare(args *Args) (isHello bool, ok bool) {
242 // If MAIL FROM is set, that identity is used. Otherwise the EHLO identity is used.
243 // MAIL FROM is preferred, because if we accept the message, and we have to send a
244 // DSN, it helps to know it is a verified sender. If we would check an EHLO
245 // identity, and it is different from the MAIL FROM, we may be sending the DSN to
246 // an address with a domain that would not allow sending from the originating IP.
247 // The RFC seems a bit confused, ../rfc/7208:778 implies MAIL FROM is preferred,
248 // but ../rfc/7208:424 mentions that a MAIL FROM check can be avoided by first
249 // doing HELO.
250
251 args.explanation = nil
252 args.dnsRequests = nil
253 args.voidLookups = nil
254 if args.MailFromDomain.IsZero() {
255 // If there is on EHLO, and it is an IP, there is nothing to SPF-validate.
256 if !args.HelloDomain.IsDomain() {
257 return false, false
258 }
259 // If we have a mailfrom, we also have a localpart. But for EHLO we won't. ../rfc/7208:810
260 args.senderLocalpart = "postmaster"
261 args.senderDomain = args.HelloDomain.Domain
262 isHello = true
263 } else {
264 args.senderLocalpart = args.MailFromLocalpart
265 args.senderDomain = args.MailFromDomain
266 }
267 args.domain = args.senderDomain
268 return isHello, true
269}
270
271// lookup spf record, then evaluate args against it.
272func checkHost(ctx context.Context, log mlog.Log, resolver dns.Resolver, args Args) (rstatus Status, mechanism, rexplanation string, rauthentic bool, rerr error) {
273 status, _, record, rauthentic, err := Lookup(ctx, log.Logger, resolver, args.domain)
274 if err != nil {
275 return status, "", "", rauthentic, err
276 }
277
278 var evalAuthentic bool
279 rstatus, mechanism, rexplanation, evalAuthentic, rerr = evaluate(ctx, log, record, resolver, args)
280 rauthentic = rauthentic && evalAuthentic
281 return
282}
283
284// Evaluate evaluates the IP and names from args against the SPF DNS record for the domain.
285func Evaluate(ctx context.Context, elog *slog.Logger, record *Record, resolver dns.Resolver, args Args) (rstatus Status, mechanism, rexplanation string, rauthentic bool, rerr error) {
286 log := mlog.New("spf", elog)
287 _, ok := prepare(&args)
288 if !ok {
289 return StatusNone, "default", "", false, fmt.Errorf("no domain name to validate")
290 }
291 return evaluate(ctx, log, record, resolver, args)
292}
293
294// evaluate RemoteIP against domain from args, given record.
295func evaluate(ctx context.Context, log mlog.Log, record *Record, resolver dns.Resolver, args Args) (rstatus Status, mechanism, rexplanation string, rauthentic bool, rerr error) {
296 start := time.Now()
297 defer func() {
298 log.Debugx("spf evaluate result", rerr,
299 slog.Int("dnsrequests", *args.dnsRequests),
300 slog.Int("voidlookups", *args.voidLookups),
301 slog.Any("domain", args.domain),
302 slog.Any("status", rstatus),
303 slog.String("mechanism", mechanism),
304 slog.String("explanation", rexplanation),
305 slog.Duration("duration", time.Since(start)))
306 }()
307
308 if args.dnsRequests == nil {
309 args.dnsRequests = new(int)
310 args.voidLookups = new(int)
311 }
312
313 // Response is authentic until we find a non-authentic DNS response.
314 rauthentic = true
315
316 // To4 returns nil for an IPv6 address. To16 will return an IPv4-to-IPv6-mapped address.
317 var remote6 net.IP
318 remote4 := args.RemoteIP.To4()
319 if remote4 == nil {
320 remote6 = args.RemoteIP.To16()
321 }
322
323 // Check if ip matches remote ip, taking cidr mask into account.
324 checkIP := func(ip net.IP, d Directive) bool {
325 // ../rfc/7208:1097
326 if remote4 != nil {
327 ip4 := ip.To4()
328 if ip4 == nil {
329 return false
330 }
331 ones := 32
332 if d.IP4CIDRLen != nil {
333 ones = *d.IP4CIDRLen
334 }
335 mask := net.CIDRMask(ones, 32)
336 return ip4.Mask(mask).Equal(remote4.Mask(mask))
337 }
338
339 ip6 := ip.To16()
340 if ip6 == nil {
341 return false
342 }
343 ones := 128
344 if d.IP6CIDRLen != nil {
345 ones = *d.IP6CIDRLen
346 }
347 mask := net.CIDRMask(ones, 128)
348 return ip6.Mask(mask).Equal(remote6.Mask(mask))
349 }
350
351 // Used for "a" and "mx".
352 checkHostIP := func(domain dns.Domain, d Directive, args *Args) (bool, Status, error) {
353 ips, result, err := resolver.LookupIP(ctx, "ip", domain.ASCII+".")
354 rauthentic = rauthentic && result.Authentic
355 trackVoidLookup(err, args)
356 // If "not found", we must ignore the error and treat as zero records in answer. ../rfc/7208:1116
357 if err != nil && !dns.IsNotFound(err) {
358 return false, StatusTemperror, err
359 }
360 for _, ip := range ips {
361 if checkIP(ip, d) {
362 return true, StatusPass, nil
363 }
364 }
365 return false, StatusNone, nil
366 }
367
368 for _, d := range record.Directives {
369 var match bool
370
371 switch d.Mechanism {
372 case "include", "a", "mx", "ptr", "exists":
373 if err := trackLookupLimits(&args); err != nil {
374 return StatusPermerror, d.MechanismString(), "", rauthentic, err
375 }
376 }
377
378 switch d.Mechanism {
379 case "all":
380 // ../rfc/7208:1127
381 match = true
382
383 case "include":
384 // ../rfc/7208:1143
385 name, authentic, err := expandDomainSpecDNS(ctx, resolver, d.DomainSpec, args)
386 rauthentic = rauthentic && authentic
387 if err != nil {
388 return StatusPermerror, d.MechanismString(), "", rauthentic, fmt.Errorf("expanding domain-spec for include: %w", err)
389 }
390 nargs := args
391 nargs.domain = dns.Domain{ASCII: strings.TrimSuffix(name, ".")}
392 nargs.explanation = &record.Explanation // ../rfc/7208:1548
393 status, _, _, authentic, err := checkHost(ctx, log, resolver, nargs)
394 rauthentic = rauthentic && authentic
395 // ../rfc/7208:1202
396 switch status {
397 case StatusPass:
398 match = true
399 case StatusTemperror:
400 return StatusTemperror, d.MechanismString(), "", rauthentic, fmt.Errorf("include %q: %w", name, err)
401 case StatusPermerror, StatusNone:
402 return StatusPermerror, d.MechanismString(), "", rauthentic, fmt.Errorf("include %q resulted in status %q: %w", name, status, err)
403 }
404
405 case "a":
406 // ../rfc/7208:1249
407 // note: the syntax for DomainSpec hints that macros should be expanded. But
408 // expansion is explicitly documented, and only for "include", "exists" and
409 // "redirect". This reason for this could be low-effort reuse of the domain-spec
410 // ABNF rule. It could be an oversight. We are not implementing expansion for the
411 // mechanism for which it isn't specified.
412 host, err := evaluateDomainSpec(d.DomainSpec, args.domain)
413 if err != nil {
414 return StatusPermerror, d.MechanismString(), "", rauthentic, err
415 }
416 hmatch, status, err := checkHostIP(host, d, &args)
417 if err != nil {
418 return status, d.MechanismString(), "", rauthentic, err
419 }
420 match = hmatch
421
422 case "mx":
423 // ../rfc/7208:1262
424 host, err := evaluateDomainSpec(d.DomainSpec, args.domain)
425 if err != nil {
426 return StatusPermerror, d.MechanismString(), "", rauthentic, err
427 }
428 // Note: LookupMX can return an error and still return MX records.
429 mxs, result, err := resolver.LookupMX(ctx, host.ASCII+".")
430 rauthentic = rauthentic && result.Authentic
431 trackVoidLookup(err, &args)
432 // note: we handle "not found" simply as a result of zero mx records.
433 if err != nil && !dns.IsNotFound(err) {
434 return StatusTemperror, d.MechanismString(), "", rauthentic, err
435 }
436 if err == nil && len(mxs) == 1 && mxs[0].Host == "." {
437 // Explicitly no MX.
438 break
439 }
440 for i, mx := range mxs {
441 // ../rfc/7208:947 says that each mx record cannot result in more than 10 DNS
442 // requests. This seems independent of the overall limit of 10 DNS requests. So an
443 // MX request resulting in 11 names is valid, but we must return a permerror if we
444 // found no match before the 11th name.
445 // ../rfc/7208:945
446 if i >= 10 {
447 return StatusPermerror, d.MechanismString(), "", rauthentic, ErrTooManyDNSRequests
448 }
449 // Parsing lax (unless in pedantic mode) for MX targets with underscores as seen in the wild.
450 mxd, err := dns.ParseDomainLax(strings.TrimSuffix(mx.Host, "."))
451 if err != nil {
452 return StatusPermerror, d.MechanismString(), "", rauthentic, err
453 }
454 hmatch, status, err := checkHostIP(mxd, d, &args)
455 if err != nil {
456 return status, d.MechanismString(), "", rauthentic, err
457 }
458 if hmatch {
459 match = hmatch
460 break
461 }
462 }
463
464 case "ptr":
465 // ../rfc/7208:1281
466 host, err := evaluateDomainSpec(d.DomainSpec, args.domain)
467 if err != nil {
468 return StatusPermerror, d.MechanismString(), "", rauthentic, err
469 }
470
471 rnames, result, err := resolver.LookupAddr(ctx, args.RemoteIP.String())
472 rauthentic = rauthentic && result.Authentic
473 trackVoidLookup(err, &args)
474 if err != nil && !dns.IsNotFound(err) {
475 return StatusTemperror, d.MechanismString(), "", rauthentic, err
476 }
477 lookups := 0
478 ptrnames:
479 for _, rname := range rnames {
480 rd, err := dns.ParseDomain(strings.TrimSuffix(rname, "."))
481 if err != nil {
482 log.Errorx("bad address in ptr record", err, slog.String("address", rname))
483 continue
484 }
485 // ../rfc/7208-eid4751 ../rfc/7208:1323
486 if rd.ASCII != host.ASCII && !strings.HasSuffix(rd.ASCII, "."+host.ASCII) {
487 continue
488 }
489
490 // ../rfc/7208:963, we must ignore entries after the first 10.
491 if lookups >= 10 {
492 break
493 }
494 lookups++
495 ips, result, err := resolver.LookupIP(ctx, "ip", rd.ASCII+".")
496 rauthentic = rauthentic && result.Authentic
497 trackVoidLookup(err, &args)
498 for _, ip := range ips {
499 if checkIP(ip, d) {
500 match = true
501 break ptrnames
502 }
503 }
504 }
505
506 // ../rfc/7208:1351
507 case "ip4":
508 if remote4 != nil {
509 match = checkIP(d.IP, d)
510 }
511 case "ip6":
512 if remote6 != nil {
513 match = checkIP(d.IP, d)
514 }
515
516 case "exists":
517 // ../rfc/7208:1382
518 name, authentic, err := expandDomainSpecDNS(ctx, resolver, d.DomainSpec, args)
519 rauthentic = rauthentic && authentic
520 if err != nil {
521 return StatusPermerror, d.MechanismString(), "", rauthentic, fmt.Errorf("expanding domain-spec for exists: %w", err)
522 }
523
524 ips, result, err := resolver.LookupIP(ctx, "ip4", ensureAbsDNS(name))
525 rauthentic = rauthentic && result.Authentic
526 // Note: we do count this for void lookups, as that is an anti-abuse mechanism.
527 // ../rfc/7208:1382 does not say anything special, so ../rfc/7208:984 applies.
528 trackVoidLookup(err, &args)
529 if err != nil && !dns.IsNotFound(err) {
530 return StatusTemperror, d.MechanismString(), "", rauthentic, err
531 }
532 match = len(ips) > 0
533
534 default:
535 return StatusNone, d.MechanismString(), "", rauthentic, fmt.Errorf("internal error, unexpected mechanism %q", d.Mechanism)
536 }
537
538 if !match {
539 continue
540 }
541 switch d.Qualifier {
542 case "", "+":
543 return StatusPass, d.MechanismString(), "", rauthentic, nil
544 case "?":
545 return StatusNeutral, d.MechanismString(), "", rauthentic, nil
546 case "-":
547 nargs := args
548 // ../rfc/7208:1489
549 authentic, expl := explanation(ctx, resolver, record, nargs)
550 rauthentic = rauthentic && authentic
551 return StatusFail, d.MechanismString(), expl, rauthentic, nil
552 case "~":
553 return StatusSoftfail, d.MechanismString(), "", rauthentic, nil
554 }
555 return StatusNone, d.MechanismString(), "", rauthentic, fmt.Errorf("internal error, unexpected qualifier %q", d.Qualifier)
556 }
557
558 if record.Redirect != "" {
559 // We only know "redirect" for evaluating purposes, ignoring any others. ../rfc/7208:1423
560
561 // ../rfc/7208:1440
562 name, authentic, err := expandDomainSpecDNS(ctx, resolver, record.Redirect, args)
563 rauthentic = rauthentic && authentic
564 if err != nil {
565 return StatusPermerror, "", "", rauthentic, fmt.Errorf("expanding domain-spec: %w", err)
566 }
567 nargs := args
568 nargs.domain = dns.Domain{ASCII: strings.TrimSuffix(name, ".")}
569 nargs.explanation = nil // ../rfc/7208:1548
570 status, mechanism, expl, authentic, err := checkHost(ctx, log, resolver, nargs)
571 rauthentic = rauthentic && authentic
572 if status == StatusNone {
573 return StatusPermerror, mechanism, "", rauthentic, err
574 }
575 return status, mechanism, expl, rauthentic, err
576 }
577
578 // ../rfc/7208:996 ../rfc/7208:2095
579 return StatusNeutral, "default", "", rauthentic, nil
580}
581
582// evaluateDomainSpec returns the parsed dns domain for spec if non-empty, and
583// otherwise returns d, which must be the Domain in checkHost Args.
584func evaluateDomainSpec(spec string, d dns.Domain) (dns.Domain, error) {
585 // ../rfc/7208:1037
586 if spec == "" {
587 return d, nil
588 }
589 d, err := dns.ParseDomain(spec)
590 if err != nil {
591 return d, fmt.Errorf("%w: %s", ErrName, err)
592 }
593 return d, nil
594}
595
596func expandDomainSpecDNS(ctx context.Context, resolver dns.Resolver, domainSpec string, args Args) (string, bool, error) {
597 return expandDomainSpec(ctx, resolver, domainSpec, args, true)
598}
599
600func expandDomainSpecExp(ctx context.Context, resolver dns.Resolver, domainSpec string, args Args) (string, bool, error) {
601 return expandDomainSpec(ctx, resolver, domainSpec, args, false)
602}
603
604// expandDomainSpec interprets macros in domainSpec.
605// The expansion can fail due to macro syntax errors or DNS errors.
606// Caller should typically treat failures as StatusPermerror. ../rfc/7208:1641
607// ../rfc/7208:1639
608// ../rfc/7208:1047
609func expandDomainSpec(ctx context.Context, resolver dns.Resolver, domainSpec string, args Args, dns bool) (string, bool, error) {
610 exp := !dns
611
612 rauthentic := true // Until non-authentic record is found.
613
614 s := domainSpec
615
616 b := &strings.Builder{}
617 i := 0
618 n := len(s)
619 for i < n {
620 c := s[i]
621 i++
622 if c != '%' {
623 b.WriteByte(c)
624 continue
625 }
626
627 if i >= n {
628 return "", rauthentic, fmt.Errorf("%w: trailing bare %%", ErrMacroSyntax)
629 }
630 c = s[i]
631 i++
632 if c == '%' {
633 b.WriteByte(c)
634 continue
635 } else if c == '_' {
636 b.WriteByte(' ')
637 continue
638 } else if c == '-' {
639 b.WriteString("%20")
640 continue
641 } else if c != '{' {
642 return "", rauthentic, fmt.Errorf("%w: invalid macro opening %%%c", ErrMacroSyntax, c)
643 }
644
645 if i >= n {
646 return "", rauthentic, fmt.Errorf("%w: missing macro ending }", ErrMacroSyntax)
647 }
648 c = s[i]
649 i++
650
651 upper := false
652 if c >= 'A' && c <= 'Z' {
653 upper = true
654 c += 'a' - 'A'
655 }
656
657 var v string
658 switch c {
659 case 's':
660 // 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
661 v = smtp.NewAddress(args.senderLocalpart, args.senderDomain).String()
662 case 'l':
663 // todo: same about utf8 as for 's'.
664 v = string(args.senderLocalpart)
665 case 'o':
666 v = args.senderDomain.ASCII
667 case 'd':
668 v = args.domain.ASCII
669 case 'i':
670 v = expandIP(args.RemoteIP)
671 case 'p':
672 // ../rfc/7208:937
673 if err := trackLookupLimits(&args); err != nil {
674 return "", rauthentic, err
675 }
676 names, result, err := resolver.LookupAddr(ctx, args.RemoteIP.String())
677 rauthentic = rauthentic && result.Authentic
678 trackVoidLookup(err, &args)
679 if len(names) == 0 || err != nil {
680 // ../rfc/7208:1709
681 v = "unknown"
682 break
683 }
684
685 // Verify finds the first dns name that resolves to the remote ip.
686 verify := func(matchfn func(string) bool) (string, error) {
687 for _, name := range names {
688 if !matchfn(name) {
689 continue
690 }
691 ips, result, err := resolver.LookupIP(ctx, "ip", name)
692 rauthentic = rauthentic && result.Authentic
693 trackVoidLookup(err, &args)
694 // ../rfc/7208:1714, we don't have to check other errors.
695 for _, ip := range ips {
696 if ip.Equal(args.RemoteIP) {
697 return name, nil
698 }
699 }
700 }
701 return "", nil
702 }
703
704 // First exact domain name matches, then subdomains, finally other names.
705 domain := args.domain.ASCII + "."
706 dotdomain := "." + domain
707 v, err = verify(func(name string) bool { return name == domain })
708 if err != nil {
709 return "", rauthentic, err
710 }
711 if v == "" {
712 v, err = verify(func(name string) bool { return strings.HasSuffix(name, dotdomain) })
713 if err != nil {
714 return "", rauthentic, err
715 }
716 }
717 if v == "" {
718 v, err = verify(func(name string) bool { return name != domain && !strings.HasSuffix(name, dotdomain) })
719 if err != nil {
720 return "", rauthentic, err
721 }
722 }
723 if v == "" {
724 // ../rfc/7208:1709
725 v = "unknown"
726 }
727
728 case 'v':
729 if args.RemoteIP.To4() != nil {
730 v = "in-addr"
731 } else {
732 v = "ip6"
733 }
734 case 'h':
735 if args.HelloDomain.IsIP() {
736 // ../rfc/7208:1621 explicitly says "domain", not "ip". We'll handle IP, probably does no harm.
737 v = expandIP(args.HelloDomain.IP)
738 } else {
739 v = args.HelloDomain.Domain.ASCII
740 }
741 case 'c', 'r', 't':
742 if !exp {
743 return "", rauthentic, fmt.Errorf("%w: macro letter %c only allowed in exp", ErrMacroSyntax, c)
744 }
745 switch c {
746 case 'c':
747 v = args.LocalIP.String()
748 case 'r':
749 v = args.LocalHostname.ASCII
750 case 't':
751 v = fmt.Sprintf("%d", timeNow().Unix())
752 }
753 default:
754 return "", rauthentic, fmt.Errorf("%w: unknown macro letter %c", ErrMacroSyntax, c)
755 }
756
757 digits := ""
758 for i < n && s[i] >= '0' && s[i] <= '9' {
759 digits += string(s[i])
760 i++
761 }
762 nlabels := -1
763 if digits != "" {
764 v, err := strconv.Atoi(digits)
765 if err != nil {
766 return "", rauthentic, fmt.Errorf("%w: bad macro transformer digits %q: %s", ErrMacroSyntax, digits, err)
767 }
768 nlabels = v
769 if nlabels == 0 {
770 return "", rauthentic, fmt.Errorf("%w: zero labels for digits transformer", ErrMacroSyntax)
771 }
772 }
773
774 // If "r" follows, we must reverse the resulting name, splitting on a dot by default.
775 // ../rfc/7208:1655
776 reverse := false
777 if i < n && (s[i] == 'r' || s[i] == 'R') {
778 reverse = true
779 i++
780 }
781
782 // Delimiters to split on, for subset of labels and/or reversing.
783 delim := ""
784 for i < n {
785 switch s[i] {
786 case '.', '-', '+', ',', '/', '_', '=':
787 delim += string(s[i])
788 i++
789 continue
790 }
791 break
792 }
793
794 if i >= n || s[i] != '}' {
795 return "", rauthentic, fmt.Errorf("%w: missing closing } for macro", ErrMacroSyntax)
796 }
797 i++
798
799 // Only split and subset and/or reverse if necessary.
800 if nlabels >= 0 || reverse || delim != "" {
801 if delim == "" {
802 delim = "."
803 }
804 t := split(v, delim)
805 // ../rfc/7208:1655
806 if reverse {
807 nt := len(t)
808 h := nt / 2
809 for i := 0; i < h; i++ {
810 t[i], t[nt-1-i] = t[nt-1-i], t[i]
811 }
812 }
813 if nlabels > 0 && nlabels < len(t) {
814 t = t[len(t)-nlabels:]
815 }
816 // Always join on dot. ../rfc/7208:1659
817 v = strings.Join(t, ".")
818 }
819
820 // ../rfc/7208:1755
821 if upper {
822 v = url.QueryEscape(v)
823 }
824
825 b.WriteString(v)
826 }
827 r := b.String()
828 if dns {
829 isAbs := strings.HasSuffix(r, ".")
830 r = ensureAbsDNS(r)
831 if err := validateDNS(r); err != nil {
832 return "", rauthentic, fmt.Errorf("invalid dns name: %s", err)
833 }
834 // If resulting name is too large, cut off labels on the left until it fits. ../rfc/7208:1749
835 if len(r) > 253+1 {
836 labels := strings.Split(r, ".")
837 for i := range labels {
838 if i == len(labels)-1 {
839 return "", rauthentic, fmt.Errorf("expanded dns name too long")
840 }
841 s := strings.Join(labels[i+1:], ".")
842 if len(s) <= 254 {
843 r = s
844 break
845 }
846 }
847 }
848 if !isAbs {
849 r = r[:len(r)-1]
850 }
851 }
852 return r, rauthentic, nil
853}
854
855func expandIP(ip net.IP) string {
856 ip4 := ip.To4()
857 if ip4 != nil {
858 return ip4.String()
859 }
860 v := ""
861 for i, b := range ip.To16() {
862 if i > 0 {
863 v += "."
864 }
865 v += fmt.Sprintf("%x.%x", b>>4, b&0xf)
866 }
867 return v
868}
869
870// validateDNS checks if a DNS name is valid. Must not end in dot. This does not
871// check valid host names, e.g. _ is allowed in DNS but not in a host name.
872func validateDNS(s string) error {
873 // ../rfc/7208:800
874 // note: we are not checking for max 253 bytes length, because one of the callers may be chopping off labels to "correct" the name.
875 labels := strings.Split(s, ".")
876 if len(labels) > 128 {
877 return fmt.Errorf("more than 128 labels")
878 }
879 for _, label := range labels[:len(labels)-1] {
880 if len(label) > 63 {
881 return fmt.Errorf("label longer than 63 bytes")
882 }
883
884 if label == "" {
885 return fmt.Errorf("empty dns label")
886 }
887 }
888 return nil
889}
890
891func split(v, delim string) (r []string) {
892 isdelim := func(c rune) bool {
893 for _, d := range delim {
894 if d == c {
895 return true
896 }
897 }
898 return false
899 }
900
901 s := 0
902 for i, c := range v {
903 if isdelim(c) {
904 r = append(r, v[s:i])
905 s = i + 1
906 }
907 }
908 r = append(r, v[s:])
909 return r
910}
911
912// explanation does a best-effort attempt to fetch an explanation for a StatusFail response.
913// If no explanation could be composed, an empty string is returned.
914func explanation(ctx context.Context, resolver dns.Resolver, r *Record, args Args) (bool, string) {
915 // ../rfc/7208:1485
916
917 // If this record is the result of an "include", we have to use the explanation
918 // string of the original domain, not of this domain.
919 // ../rfc/7208:1548
920 expl := r.Explanation
921 if args.explanation != nil {
922 expl = *args.explanation
923 }
924
925 // ../rfc/7208:1491
926 if expl == "" {
927 return true, ""
928 }
929
930 // Limits for dns requests and void lookups should not be taken into account.
931 // Starting with zero ensures they aren't triggered.
932 args.dnsRequests = new(int)
933 args.voidLookups = new(int)
934 name, authentic, err := expandDomainSpecDNS(ctx, resolver, r.Explanation, args)
935 if err != nil || name == "" {
936 return authentic, ""
937 }
938 txts, result, err := resolver.LookupTXT(ctx, ensureAbsDNS(name))
939 authentic = authentic && result.Authentic
940 if err != nil || len(txts) == 0 {
941 return authentic, ""
942 }
943 txt := strings.Join(txts, "")
944 s, exauthentic, err := expandDomainSpecExp(ctx, resolver, txt, args)
945 authentic = authentic && exauthentic
946 if err != nil {
947 return authentic, ""
948 }
949 return authentic, s
950}
951
952func ensureAbsDNS(s string) string {
953 if !strings.HasSuffix(s, ".") {
954 return s + "."
955 }
956 return s
957}
958
959func trackLookupLimits(args *Args) error {
960 // ../rfc/7208:937
961 if *args.dnsRequests >= dnsRequestsMax {
962 return ErrTooManyDNSRequests
963 }
964 // ../rfc/7208:988
965 if *args.voidLookups >= voidLookupsMax {
966 return ErrTooManyVoidLookups
967 }
968 *args.dnsRequests++
969 return nil
970}
971
972func trackVoidLookup(err error, args *Args) {
973 if dns.IsNotFound(err) {
974 *args.voidLookups++
975 }
976}
977