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