mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 17:04:39 +03:00
mox!
This commit is contained in:
239
dmarc/dmarc.go
Normal file
239
dmarc/dmarc.go
Normal file
@ -0,0 +1,239 @@
|
||||
// Package dmarc implements DMARC (Domain-based Message Authentication,
|
||||
// Reporting, and Conformance; RFC 7489) verification.
|
||||
//
|
||||
// DMARC is a mechanism for verifying ("authenticating") the address in the "From"
|
||||
// message header, since users will look at that header to identify the sender of a
|
||||
// message. DMARC compares the "From"-(sub)domain against the SPF and/or
|
||||
// DKIM-validated domains, based on the DMARC policy that a domain has published in
|
||||
// DNS as TXT record under "_dmarc.<domain>". A DMARC policy can also ask for
|
||||
// feedback about evaluations by other email servers, for monitoring/debugging
|
||||
// problems with email delivery.
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
mathrand "math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
|
||||
"github.com/mjl-/mox/dkim"
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
"github.com/mjl-/mox/publicsuffix"
|
||||
"github.com/mjl-/mox/spf"
|
||||
)
|
||||
|
||||
var xlog = mlog.New("dmarc")
|
||||
|
||||
var (
|
||||
metricDMARCVerify = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "mox_dmarc_verify_duration_seconds",
|
||||
Help: "DMARC verify, including lookup, duration and result.",
|
||||
Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20},
|
||||
},
|
||||
[]string{
|
||||
"status",
|
||||
"reject", // yes/no
|
||||
"use", // yes/no, if policy is used after random selection
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
// link errata:
|
||||
// ../rfc/7489-eid5440 ../rfc/7489:1585
|
||||
|
||||
// Lookup errors.
|
||||
var (
|
||||
ErrNoRecord = errors.New("dmarc: no dmarc dns record")
|
||||
ErrMultipleRecords = errors.New("dmarc: multiple dmarc dns records") // Must also be treated as if domain does not implement DMARC.
|
||||
ErrDNS = errors.New("dmarc: dns lookup")
|
||||
ErrSyntax = errors.New("dmarc: malformed dmarc dns record")
|
||||
)
|
||||
|
||||
// Status is the result of DMARC policy evaluation, for use in an Authentication-Results header.
|
||||
type Status string
|
||||
|
||||
// ../rfc/7489:2339
|
||||
|
||||
const (
|
||||
StatusNone Status = "none" // No DMARC TXT DNS record found.
|
||||
StatusPass Status = "pass" // SPF and/or DKIM pass with identifier alignment.
|
||||
StatusFail Status = "fail" // Either both SPF and DKIM failed or identifier did not align with a pass.
|
||||
StatusTemperror Status = "temperror" // Typically a DNS lookup. A later attempt may results in a conclusion.
|
||||
StatusPermerror Status = "permerror" // Typically a malformed DMARC DNS record.
|
||||
)
|
||||
|
||||
// Result is a DMARC policy evaluation.
|
||||
type Result struct {
|
||||
// Whether to reject the message based on policies. If false, the message should
|
||||
// not necessarily be accepted, e.g. due to reputation or content-based analysis.
|
||||
Reject bool
|
||||
// Result of DMARC validation. A message can fail validation, but still
|
||||
// not be rejected, e.g. if the policy is "none".
|
||||
Status Status
|
||||
// Domain with the DMARC DNS record. May be the organizational domain instead of
|
||||
// the domain in the From-header.
|
||||
Domain dns.Domain
|
||||
// Parsed DMARC record.
|
||||
Record *Record
|
||||
// Details about possible error condition, e.g. when parsing the DMARC record failed.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Lookup looks up the DMARC TXT record at "_dmarc.<domain>" for the domain in the
|
||||
// "From"-header of a message.
|
||||
//
|
||||
// If no DMARC record is found for the "From"-domain, another lookup is done at
|
||||
// the organizational domain of the domain (if different). The organizational
|
||||
// domain is determined using the public suffix list. E.g. for
|
||||
// "sub.example.com", the organizational domain is "example.com". The returned
|
||||
// domain is the domain with the DMARC record.
|
||||
func Lookup(ctx context.Context, resolver dns.Resolver, from dns.Domain) (status Status, domain dns.Domain, record *Record, txt string, rerr error) {
|
||||
log := xlog.WithContext(ctx)
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
log.Debugx("dmarc lookup result", rerr, mlog.Field("fromdomain", from), mlog.Field("status", status), mlog.Field("domain", domain), mlog.Field("record", record), mlog.Field("duration", time.Since(start)))
|
||||
}()
|
||||
|
||||
// ../rfc/7489:859 ../rfc/7489:1370
|
||||
domain = from
|
||||
status, record, txt, err := lookupRecord(ctx, resolver, domain)
|
||||
if status != StatusNone {
|
||||
return status, domain, record, txt, err
|
||||
}
|
||||
if record == nil {
|
||||
// ../rfc/7489:761 ../rfc/7489:1377
|
||||
domain = publicsuffix.Lookup(ctx, from)
|
||||
if domain == from {
|
||||
return StatusNone, domain, nil, txt, err
|
||||
}
|
||||
|
||||
status, record, txt, err = lookupRecord(ctx, resolver, domain)
|
||||
}
|
||||
return status, domain, record, txt, err
|
||||
}
|
||||
|
||||
func lookupRecord(ctx context.Context, resolver dns.Resolver, domain dns.Domain) (Status, *Record, string, error) {
|
||||
name := "_dmarc." + domain.ASCII + "."
|
||||
txts, err := dns.WithPackage(resolver, "dmarc").LookupTXT(ctx, name)
|
||||
if err != nil && !dns.IsNotFound(err) {
|
||||
return StatusTemperror, nil, "", fmt.Errorf("%w: %s", ErrDNS, err)
|
||||
}
|
||||
var record *Record
|
||||
var text string
|
||||
var rerr error = ErrNoRecord
|
||||
for _, txt := range txts {
|
||||
r, isdmarc, err := ParseRecord(txt)
|
||||
if !isdmarc {
|
||||
// ../rfc/7489:1374
|
||||
continue
|
||||
} else if err != nil {
|
||||
return StatusPermerror, nil, text, fmt.Errorf("%w: %s", ErrSyntax, err)
|
||||
}
|
||||
if record != nil {
|
||||
// ../ ../rfc/7489:1388
|
||||
return StatusNone, nil, "", ErrMultipleRecords
|
||||
}
|
||||
text = txt
|
||||
record = r
|
||||
rerr = nil
|
||||
}
|
||||
return StatusNone, record, text, rerr
|
||||
}
|
||||
|
||||
// Verify evaluates the DMARC policy for the domain in the From-header of a
|
||||
// message given the DKIM and SPF evaluation results.
|
||||
//
|
||||
// applyRandomPercentage determines whether the records "pct" is honored. This
|
||||
// field specifies the percentage of messages the DMARC policy is applied to. It
|
||||
// is used for slow rollout of DMARC policies and should be honored during normal
|
||||
// email processing
|
||||
//
|
||||
// Verify always returns the result of verifying the DMARC policy
|
||||
// against the message (for inclusion in Authentication-Result headers).
|
||||
//
|
||||
// useResult indicates if the result should be applied in a policy decision.
|
||||
func Verify(ctx context.Context, resolver dns.Resolver, from dns.Domain, dkimResults []dkim.Result, spfResult spf.Status, spfIdentity *dns.Domain, applyRandomPercentage bool) (useResult bool, result Result) {
|
||||
log := xlog.WithContext(ctx)
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
use := "no"
|
||||
if useResult {
|
||||
use = "yes"
|
||||
}
|
||||
reject := "no"
|
||||
if result.Reject {
|
||||
reject = "yes"
|
||||
}
|
||||
metricDMARCVerify.WithLabelValues(string(result.Status), reject, use).Observe(float64(time.Since(start)) / float64(time.Second))
|
||||
log.Debugx("dmarc verify result", result.Err, mlog.Field("fromdomain", from), mlog.Field("dkimresults", dkimResults), mlog.Field("spfresult", spfResult), mlog.Field("status", result.Status), mlog.Field("reject", result.Reject), mlog.Field("use", useResult), mlog.Field("duration", time.Since(start)))
|
||||
}()
|
||||
|
||||
status, recordDomain, record, _, err := Lookup(ctx, resolver, from)
|
||||
if record == nil {
|
||||
return false, Result{false, status, recordDomain, record, err}
|
||||
}
|
||||
result.Domain = recordDomain
|
||||
result.Record = record
|
||||
|
||||
// Record can request sampling of messages to apply policy.
|
||||
// See ../rfc/7489:1432
|
||||
useResult = !applyRandomPercentage || record.Percentage == 100 || mathrand.Intn(100) < record.Percentage
|
||||
|
||||
// We reject treat "quarantine" and "reject" the same. Thus, we also don't
|
||||
// "downgrade" from reject to quarantine if this message was sampled out.
|
||||
// ../rfc/7489:1446 ../rfc/7489:1024
|
||||
if recordDomain != from && record.SubdomainPolicy != PolicyEmpty {
|
||||
result.Reject = record.SubdomainPolicy != PolicyNone
|
||||
} else {
|
||||
result.Reject = record.Policy != PolicyNone
|
||||
}
|
||||
|
||||
// ../rfc/7489:1338
|
||||
result.Status = StatusFail
|
||||
if spfResult == spf.StatusTemperror {
|
||||
result.Status = StatusTemperror
|
||||
result.Reject = false
|
||||
}
|
||||
|
||||
// Below we can do a bunch of publicsuffix lookups. Cache the results, mostly to
|
||||
// reduce log polution.
|
||||
pubsuffixes := map[dns.Domain]dns.Domain{}
|
||||
pubsuffix := func(name dns.Domain) dns.Domain {
|
||||
if r, ok := pubsuffixes[name]; ok {
|
||||
return r
|
||||
}
|
||||
r := publicsuffix.Lookup(ctx, name)
|
||||
pubsuffixes[name] = r
|
||||
return r
|
||||
}
|
||||
|
||||
// ../rfc/7489:1319
|
||||
// ../rfc/7489:544
|
||||
if spfResult == spf.StatusPass && spfIdentity != nil && (*spfIdentity == from || result.Record.ASPF == "r" && pubsuffix(from) == pubsuffix(*spfIdentity)) {
|
||||
result.Reject = false
|
||||
result.Status = StatusPass
|
||||
return
|
||||
}
|
||||
|
||||
for _, dkimResult := range dkimResults {
|
||||
if dkimResult.Status == dkim.StatusTemperror {
|
||||
result.Reject = false
|
||||
result.Status = StatusTemperror
|
||||
continue
|
||||
}
|
||||
// ../rfc/7489:511
|
||||
if dkimResult.Status == dkim.StatusPass && dkimResult.Sig != nil && (dkimResult.Sig.Domain == from || result.Record.ADKIM == "r" && pubsuffix(from) == pubsuffix(dkimResult.Sig.Domain)) {
|
||||
// ../rfc/7489:535
|
||||
result.Reject = false
|
||||
result.Status = StatusPass
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
275
dmarc/dmarc_test.go
Normal file
275
dmarc/dmarc_test.go
Normal file
@ -0,0 +1,275 @@
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/dkim"
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/spf"
|
||||
)
|
||||
|
||||
func TestLookup(t *testing.T) {
|
||||
resolver := dns.MockResolver{
|
||||
TXT: map[string][]string{
|
||||
"_dmarc.simple.example.": {"v=DMARC1; p=none;"},
|
||||
"_dmarc.one.example.": {"v=DMARC1; p=none;", "other"},
|
||||
"_dmarc.temperror.example.": {"v=DMARC1; p=none;"},
|
||||
"_dmarc.multiple.example.": {"v=DMARC1; p=none;", "v=DMARC1; p=none;"},
|
||||
"_dmarc.malformed.example.": {"v=DMARC1; p=none; bogus;"},
|
||||
"_dmarc.example.com.": {"v=DMARC1; p=none;"},
|
||||
},
|
||||
Fail: map[dns.Mockreq]struct{}{
|
||||
{Type: "txt", Name: "_dmarc.temperror.example."}: {},
|
||||
},
|
||||
}
|
||||
|
||||
test := func(d string, expStatus Status, expDomain string, expRecord *Record, expErr error) {
|
||||
t.Helper()
|
||||
|
||||
status, dom, record, _, err := Lookup(context.Background(), resolver, dns.Domain{ASCII: d})
|
||||
if (err == nil) != (expErr == nil) || err != nil && !errors.Is(err, expErr) {
|
||||
t.Fatalf("got err %#v, expected %#v", err, expErr)
|
||||
}
|
||||
expd := dns.Domain{ASCII: expDomain}
|
||||
if status != expStatus || dom != expd || !reflect.DeepEqual(record, expRecord) {
|
||||
t.Fatalf("got status %v, dom %v, record %#v, expected %v %v %#v", status, dom, record, expStatus, expDomain, expRecord)
|
||||
}
|
||||
}
|
||||
|
||||
r := DefaultRecord
|
||||
r.Policy = PolicyNone
|
||||
test("simple.example", StatusNone, "simple.example", &r, nil)
|
||||
test("one.example", StatusNone, "one.example", &r, nil)
|
||||
test("absent.example", StatusNone, "absent.example", nil, ErrNoRecord)
|
||||
test("multiple.example", StatusNone, "multiple.example", nil, ErrMultipleRecords)
|
||||
test("malformed.example", StatusPermerror, "malformed.example", nil, ErrSyntax)
|
||||
test("temperror.example", StatusTemperror, "temperror.example", nil, ErrDNS)
|
||||
test("sub.example.com", StatusNone, "example.com", &r, nil) // Policy published at organizational domain, public suffix.
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
resolver := dns.MockResolver{
|
||||
TXT: map[string][]string{
|
||||
"_dmarc.reject.example.": {"v=DMARC1; p=reject"},
|
||||
"_dmarc.strict.example.": {"v=DMARC1; p=reject; adkim=s; aspf=s"},
|
||||
"_dmarc.test.example.": {"v=DMARC1; p=reject; pct=0"},
|
||||
"_dmarc.subnone.example.": {"v=DMARC1; p=reject; sp=none"},
|
||||
"_dmarc.none.example.": {"v=DMARC1; p=none"},
|
||||
"_dmarc.malformed.example.": {"v=DMARC1; p=none; bogus"},
|
||||
"_dmarc.example.com.": {"v=DMARC1; p=reject"},
|
||||
},
|
||||
Fail: map[dns.Mockreq]struct{}{
|
||||
{Type: "txt", Name: "_dmarc.temperror.example."}: {},
|
||||
},
|
||||
}
|
||||
|
||||
equalResult := func(got, exp Result) bool {
|
||||
if reflect.DeepEqual(got, exp) {
|
||||
return true
|
||||
}
|
||||
if got.Err != nil && exp.Err != nil && (got.Err == exp.Err || errors.Is(got.Err, exp.Err)) {
|
||||
got.Err = nil
|
||||
exp.Err = nil
|
||||
return reflect.DeepEqual(got, exp)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
test := func(fromDom string, dkimResults []dkim.Result, spfResult spf.Status, spfIdentity *dns.Domain, expUseResult bool, expResult Result) {
|
||||
t.Helper()
|
||||
|
||||
from, err := dns.ParseDomain(fromDom)
|
||||
if err != nil {
|
||||
t.Fatalf("parsing domain: %v", err)
|
||||
}
|
||||
useResult, result := Verify(context.Background(), resolver, from, dkimResults, spfResult, spfIdentity, true)
|
||||
if useResult != expUseResult || !equalResult(result, expResult) {
|
||||
t.Fatalf("verify: got useResult %v, result %#v, expected %v %#v", useResult, result, expUseResult, expResult)
|
||||
}
|
||||
}
|
||||
|
||||
// Basic case, reject policy and no dkim or spf results.
|
||||
reject := DefaultRecord
|
||||
reject.Policy = PolicyReject
|
||||
test("reject.example",
|
||||
[]dkim.Result{},
|
||||
spf.StatusNone,
|
||||
nil,
|
||||
true, Result{true, StatusFail, dns.Domain{ASCII: "reject.example"}, &reject, nil},
|
||||
)
|
||||
|
||||
// Accept with spf pass.
|
||||
test("reject.example",
|
||||
[]dkim.Result{},
|
||||
spf.StatusPass,
|
||||
&dns.Domain{ASCII: "sub.reject.example"},
|
||||
true, Result{false, StatusPass, dns.Domain{ASCII: "reject.example"}, &reject, nil},
|
||||
)
|
||||
|
||||
// Accept with dkim pass.
|
||||
test("reject.example",
|
||||
[]dkim.Result{
|
||||
{
|
||||
Status: dkim.StatusPass,
|
||||
Sig: &dkim.Sig{ // Just the minimum fields needed.
|
||||
Domain: dns.Domain{ASCII: "sub.reject.example"},
|
||||
},
|
||||
Record: &dkim.Record{},
|
||||
},
|
||||
},
|
||||
spf.StatusFail,
|
||||
&dns.Domain{ASCII: "reject.example"},
|
||||
true, Result{false, StatusPass, dns.Domain{ASCII: "reject.example"}, &reject, nil},
|
||||
)
|
||||
|
||||
// Reject due to spf and dkim "strict".
|
||||
strict := DefaultRecord
|
||||
strict.Policy = PolicyReject
|
||||
strict.ADKIM = AlignStrict
|
||||
strict.ASPF = AlignStrict
|
||||
test("strict.example",
|
||||
[]dkim.Result{
|
||||
{
|
||||
Status: dkim.StatusPass,
|
||||
Sig: &dkim.Sig{ // Just the minimum fields needed.
|
||||
Domain: dns.Domain{ASCII: "sub.strict.example"},
|
||||
},
|
||||
Record: &dkim.Record{},
|
||||
},
|
||||
},
|
||||
spf.StatusPass,
|
||||
&dns.Domain{ASCII: "sub.strict.example"},
|
||||
true, Result{true, StatusFail, dns.Domain{ASCII: "strict.example"}, &strict, nil},
|
||||
)
|
||||
|
||||
// No dmarc policy, nothing to say.
|
||||
test("absent.example",
|
||||
[]dkim.Result{},
|
||||
spf.StatusNone,
|
||||
nil,
|
||||
false, Result{false, StatusNone, dns.Domain{ASCII: "absent.example"}, nil, ErrNoRecord},
|
||||
)
|
||||
|
||||
// No dmarc policy, spf pass does nothing.
|
||||
test("absent.example",
|
||||
[]dkim.Result{},
|
||||
spf.StatusPass,
|
||||
&dns.Domain{ASCII: "absent.example"},
|
||||
false, Result{false, StatusNone, dns.Domain{ASCII: "absent.example"}, nil, ErrNoRecord},
|
||||
)
|
||||
|
||||
none := DefaultRecord
|
||||
none.Policy = PolicyNone
|
||||
// Policy none results in no reject.
|
||||
test("none.example",
|
||||
[]dkim.Result{},
|
||||
spf.StatusPass,
|
||||
&dns.Domain{ASCII: "none.example"},
|
||||
true, Result{false, StatusPass, dns.Domain{ASCII: "none.example"}, &none, nil},
|
||||
)
|
||||
|
||||
// No actual reject due to pct=0.
|
||||
testr := DefaultRecord
|
||||
testr.Policy = PolicyReject
|
||||
testr.Percentage = 0
|
||||
test("test.example",
|
||||
[]dkim.Result{},
|
||||
spf.StatusNone,
|
||||
nil,
|
||||
false, Result{true, StatusFail, dns.Domain{ASCII: "test.example"}, &testr, nil},
|
||||
)
|
||||
|
||||
// No reject if subdomain has "none" policy.
|
||||
sub := DefaultRecord
|
||||
sub.Policy = PolicyReject
|
||||
sub.SubdomainPolicy = PolicyNone
|
||||
test("sub.subnone.example",
|
||||
[]dkim.Result{},
|
||||
spf.StatusFail,
|
||||
&dns.Domain{ASCII: "sub.subnone.example"},
|
||||
true, Result{false, StatusFail, dns.Domain{ASCII: "subnone.example"}, &sub, nil},
|
||||
)
|
||||
|
||||
// No reject if spf temperror and no other pass.
|
||||
test("reject.example",
|
||||
[]dkim.Result{},
|
||||
spf.StatusTemperror,
|
||||
&dns.Domain{ASCII: "mail.reject.example"},
|
||||
true, Result{false, StatusTemperror, dns.Domain{ASCII: "reject.example"}, &reject, nil},
|
||||
)
|
||||
|
||||
// No reject if dkim temperror and no other pass.
|
||||
test("reject.example",
|
||||
[]dkim.Result{
|
||||
{
|
||||
Status: dkim.StatusTemperror,
|
||||
Sig: &dkim.Sig{ // Just the minimum fields needed.
|
||||
Domain: dns.Domain{ASCII: "sub.reject.example"},
|
||||
},
|
||||
Record: &dkim.Record{},
|
||||
},
|
||||
},
|
||||
spf.StatusNone,
|
||||
nil,
|
||||
true, Result{false, StatusTemperror, dns.Domain{ASCII: "reject.example"}, &reject, nil},
|
||||
)
|
||||
|
||||
// No reject if spf temperror but still dkim pass.
|
||||
test("reject.example",
|
||||
[]dkim.Result{
|
||||
{
|
||||
Status: dkim.StatusPass,
|
||||
Sig: &dkim.Sig{ // Just the minimum fields needed.
|
||||
Domain: dns.Domain{ASCII: "sub.reject.example"},
|
||||
},
|
||||
Record: &dkim.Record{},
|
||||
},
|
||||
},
|
||||
spf.StatusTemperror,
|
||||
&dns.Domain{ASCII: "mail.reject.example"},
|
||||
true, Result{false, StatusPass, dns.Domain{ASCII: "reject.example"}, &reject, nil},
|
||||
)
|
||||
|
||||
// No reject if dkim temperror but still spf pass.
|
||||
test("reject.example",
|
||||
[]dkim.Result{
|
||||
{
|
||||
Status: dkim.StatusTemperror,
|
||||
Sig: &dkim.Sig{ // Just the minimum fields needed.
|
||||
Domain: dns.Domain{ASCII: "sub.reject.example"},
|
||||
},
|
||||
Record: &dkim.Record{},
|
||||
},
|
||||
},
|
||||
spf.StatusPass,
|
||||
&dns.Domain{ASCII: "mail.reject.example"},
|
||||
true, Result{false, StatusPass, dns.Domain{ASCII: "reject.example"}, &reject, nil},
|
||||
)
|
||||
|
||||
// Bad DMARC record results in permerror without reject.
|
||||
test("malformed.example",
|
||||
[]dkim.Result{},
|
||||
spf.StatusNone,
|
||||
nil,
|
||||
false, Result{false, StatusPermerror, dns.Domain{ASCII: "malformed.example"}, nil, ErrSyntax},
|
||||
)
|
||||
|
||||
// DKIM domain that is higher-level than organizational can not result in a pass. ../rfc/7489:525
|
||||
test("example.com",
|
||||
[]dkim.Result{
|
||||
{
|
||||
Status: dkim.StatusPass,
|
||||
Sig: &dkim.Sig{ // Just the minimum fields needed.
|
||||
Domain: dns.Domain{ASCII: "com"},
|
||||
},
|
||||
Record: &dkim.Record{},
|
||||
},
|
||||
},
|
||||
spf.StatusNone,
|
||||
nil,
|
||||
true, Result{true, StatusFail, dns.Domain{ASCII: "example.com"}, &reject, nil},
|
||||
)
|
||||
}
|
17
dmarc/fuzz_test.go
Normal file
17
dmarc/fuzz_test.go
Normal file
@ -0,0 +1,17 @@
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func FuzzParseRecord(f *testing.F) {
|
||||
f.Add("")
|
||||
f.Add("V = DMARC1; P = reject ;\tSP=none; unknown \t=\t ignored-future-value \t ; adkim=s; aspf=s; rua=mailto:dmarc-feedback@example.com ,\t\tmailto:tld-test@thirdparty.example.net!10m; RUF=mailto:auth-reports@example.com ,\t\tmailto:tld-test@thirdparty.example.net!0G; RI = 123; FO = 0:1:d:s ; RF= afrf : other; Pct = 0")
|
||||
f.Add("v=DMARC1; rua=mailto:dmarc-feedback@example.com!99999999999999999999999999999999999999999999999")
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
r, _, err := ParseRecord(s)
|
||||
if err == nil {
|
||||
_ = r.String()
|
||||
}
|
||||
})
|
||||
}
|
343
dmarc/parse.go
Normal file
343
dmarc/parse.go
Normal file
@ -0,0 +1,343 @@
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type parseErr string
|
||||
|
||||
func (e parseErr) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
// ParseRecord parses a DMARC TXT record.
|
||||
//
|
||||
// Fields and values are are case-insensitive in DMARC are returned in lower case
|
||||
// for easy comparison.
|
||||
//
|
||||
// DefaultRecord provides default values for tags not present in s.
|
||||
func ParseRecord(s string) (record *Record, isdmarc bool, rerr error) {
|
||||
defer func() {
|
||||
x := recover()
|
||||
if x == nil {
|
||||
return
|
||||
}
|
||||
if err, ok := x.(parseErr); ok {
|
||||
rerr = err
|
||||
return
|
||||
}
|
||||
panic(x)
|
||||
}()
|
||||
|
||||
r := DefaultRecord
|
||||
p := newParser(s)
|
||||
|
||||
// v= is required and must be first. ../rfc/7489:1099
|
||||
p.xtake("v")
|
||||
p.wsp()
|
||||
p.xtake("=")
|
||||
p.wsp()
|
||||
r.Version = p.xtakecase("DMARC1")
|
||||
p.wsp()
|
||||
p.xtake(";")
|
||||
isdmarc = true
|
||||
seen := map[string]bool{}
|
||||
for {
|
||||
p.wsp()
|
||||
if p.empty() {
|
||||
break
|
||||
}
|
||||
W := p.xword()
|
||||
w := strings.ToLower(W)
|
||||
if seen[w] {
|
||||
// RFC does not say anything about duplicate tags. They can only confuse, so we
|
||||
// don't allow them.
|
||||
p.xerrorf("duplicate tag %q", W)
|
||||
}
|
||||
seen[w] = true
|
||||
p.wsp()
|
||||
p.xtake("=")
|
||||
p.wsp()
|
||||
switch w {
|
||||
default:
|
||||
// ../rfc/7489:924 implies that we should know how to parse unknown tags.
|
||||
// The formal definition at ../rfc/7489:1127 does not allow for unknown tags.
|
||||
// We just parse until the next semicolon or end.
|
||||
for !p.empty() {
|
||||
if p.peek(';') {
|
||||
break
|
||||
}
|
||||
p.xtaken(1)
|
||||
}
|
||||
case "p":
|
||||
if len(seen) != 1 {
|
||||
// ../rfc/7489:1105
|
||||
p.xerrorf("p= (policy) must be first tag")
|
||||
}
|
||||
r.Policy = DMARCPolicy(p.xtakelist("none", "quarantine", "reject"))
|
||||
case "sp":
|
||||
r.SubdomainPolicy = DMARCPolicy(p.xkeyword())
|
||||
// note: we check if the value is valid before returning.
|
||||
case "rua":
|
||||
r.AggregateReportAddresses = append(r.AggregateReportAddresses, p.xuri())
|
||||
p.wsp()
|
||||
for p.take(",") {
|
||||
p.wsp()
|
||||
r.AggregateReportAddresses = append(r.AggregateReportAddresses, p.xuri())
|
||||
p.wsp()
|
||||
}
|
||||
case "ruf":
|
||||
r.FailureReportAddresses = append(r.FailureReportAddresses, p.xuri())
|
||||
p.wsp()
|
||||
for p.take(",") {
|
||||
p.wsp()
|
||||
r.FailureReportAddresses = append(r.FailureReportAddresses, p.xuri())
|
||||
p.wsp()
|
||||
}
|
||||
case "adkim":
|
||||
r.ADKIM = Align(p.xtakelist("r", "s"))
|
||||
case "aspf":
|
||||
r.ASPF = Align(p.xtakelist("r", "s"))
|
||||
case "ri":
|
||||
r.AggregateReportingInterval = p.xnumber()
|
||||
case "fo":
|
||||
r.FailureReportingOptions = []string{p.xtakelist("0", "1", "d", "s")}
|
||||
p.wsp()
|
||||
for p.take(":") {
|
||||
p.wsp()
|
||||
r.FailureReportingOptions = append(r.FailureReportingOptions, p.xtakelist("0", "1", "d", "s"))
|
||||
p.wsp()
|
||||
}
|
||||
case "rf":
|
||||
r.ReportingFormat = []string{p.xkeyword()}
|
||||
p.wsp()
|
||||
for p.take(":") {
|
||||
p.wsp()
|
||||
r.ReportingFormat = append(r.ReportingFormat, p.xkeyword())
|
||||
p.wsp()
|
||||
}
|
||||
case "pct":
|
||||
r.Percentage = p.xnumber()
|
||||
if r.Percentage > 100 {
|
||||
p.xerrorf("bad percentage %d", r.Percentage)
|
||||
}
|
||||
}
|
||||
p.wsp()
|
||||
if !p.take(";") && !p.empty() {
|
||||
p.xerrorf("expected ;")
|
||||
}
|
||||
}
|
||||
|
||||
// ../rfc/7489:1106 says "p" is required, but ../rfc/7489:1407 implies we must be
|
||||
// able to parse a record without a "p" or with invalid "sp" tag.
|
||||
sp := r.SubdomainPolicy
|
||||
if !seen["p"] || sp != PolicyEmpty && sp != PolicyNone && sp != PolicyQuarantine && sp != PolicyReject {
|
||||
if len(r.AggregateReportAddresses) > 0 {
|
||||
r.Policy = PolicyNone
|
||||
r.SubdomainPolicy = PolicyEmpty
|
||||
} else {
|
||||
p.xerrorf("invalid (subdomain)policy and no valid aggregate reporting address")
|
||||
}
|
||||
}
|
||||
|
||||
return &r, true, nil
|
||||
}
|
||||
|
||||
type parser struct {
|
||||
s string
|
||||
lower string
|
||||
o int
|
||||
}
|
||||
|
||||
// toLower lower cases bytes that are A-Z. strings.ToLower does too much. and
|
||||
// would replace invalid bytes with unicode replacement characters, which would
|
||||
// break our requirement that offsets into the original and upper case strings
|
||||
// point to the same character.
|
||||
func toLower(s string) string {
|
||||
r := []byte(s)
|
||||
for i, c := range r {
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
r[i] = c + 0x20
|
||||
}
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
|
||||
func newParser(s string) *parser {
|
||||
return &parser{
|
||||
s: s,
|
||||
lower: toLower(s),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) xerrorf(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if p.o < len(p.s) {
|
||||
msg += fmt.Sprintf(" (remain %q)", p.s[p.o:])
|
||||
}
|
||||
panic(parseErr(msg))
|
||||
}
|
||||
|
||||
func (p *parser) empty() bool {
|
||||
return p.o >= len(p.s)
|
||||
}
|
||||
|
||||
func (p *parser) peek(b byte) bool {
|
||||
return p.o < len(p.s) && p.s[p.o] == b
|
||||
}
|
||||
|
||||
// case insensitive prefix
|
||||
func (p *parser) prefix(s string) bool {
|
||||
return strings.HasPrefix(p.lower[p.o:], s)
|
||||
}
|
||||
|
||||
func (p *parser) take(s string) bool {
|
||||
if p.prefix(s) {
|
||||
p.o += len(s)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *parser) xtaken(n int) string {
|
||||
r := p.lower[p.o : p.o+n]
|
||||
p.o += n
|
||||
return r
|
||||
}
|
||||
|
||||
func (p *parser) xtake(s string) string {
|
||||
if !p.prefix(s) {
|
||||
p.xerrorf("expected %q", s)
|
||||
}
|
||||
return p.xtaken(len(s))
|
||||
}
|
||||
|
||||
func (p *parser) xtakecase(s string) string {
|
||||
if !strings.HasPrefix(p.s[p.o:], s) {
|
||||
p.xerrorf("expected %q", s)
|
||||
}
|
||||
r := p.s[p.o : p.o+len(s)]
|
||||
p.o += len(s)
|
||||
return r
|
||||
}
|
||||
|
||||
// *WSP
|
||||
func (p *parser) wsp() {
|
||||
for !p.empty() && (p.s[p.o] == ' ' || p.s[p.o] == '\t') {
|
||||
p.o++
|
||||
}
|
||||
}
|
||||
|
||||
// take one of the strings in l.
|
||||
func (p *parser) xtakelist(l ...string) string {
|
||||
for _, s := range l {
|
||||
if p.prefix(s) {
|
||||
return p.xtaken(len(s))
|
||||
}
|
||||
}
|
||||
p.xerrorf("expected on one %v", l)
|
||||
panic("not reached")
|
||||
}
|
||||
|
||||
func (p *parser) xtakefn1case(fn func(byte, int) bool) string {
|
||||
for i, b := range []byte(p.lower[p.o:]) {
|
||||
if !fn(b, i) {
|
||||
if i == 0 {
|
||||
p.xerrorf("expected at least one char")
|
||||
}
|
||||
return p.xtaken(i)
|
||||
}
|
||||
}
|
||||
if p.empty() {
|
||||
p.xerrorf("expected at least 1 char")
|
||||
}
|
||||
r := p.s[p.o:]
|
||||
p.o += len(r)
|
||||
return r
|
||||
}
|
||||
|
||||
// used for the tag keys.
|
||||
func (p *parser) xword() string {
|
||||
return p.xtakefn1case(func(c byte, i int) bool {
|
||||
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9'
|
||||
})
|
||||
}
|
||||
|
||||
func (p *parser) xdigits() string {
|
||||
return p.xtakefn1case(func(b byte, i int) bool {
|
||||
return isdigit(b)
|
||||
})
|
||||
}
|
||||
|
||||
// ../rfc/7489:883
|
||||
// Syntax: ../rfc/7489:1132
|
||||
func (p *parser) xuri() URI {
|
||||
// Ideally, we would simply parse an URI here. But a URI can contain a semicolon so
|
||||
// could consume the rest of the DMARC record. Instead, we'll assume no one uses
|
||||
// semicolons in URIs in DMARC records and first collect
|
||||
// space/comma/semicolon/end-separated characters, then parse.
|
||||
// ../rfc/3986:684
|
||||
v := p.xtakefn1case(func(b byte, i int) bool {
|
||||
return b != ',' && b != ' ' && b != '\t' && b != ';'
|
||||
})
|
||||
t := strings.SplitN(v, "!", 2)
|
||||
u, err := url.Parse(t[0])
|
||||
if err != nil {
|
||||
p.xerrorf("parsing uri %q: %s", t[0], err)
|
||||
}
|
||||
if u.Scheme == "" {
|
||||
p.xerrorf("missing scheme in uri")
|
||||
}
|
||||
uri := URI{
|
||||
Address: t[0],
|
||||
}
|
||||
if len(t) == 2 {
|
||||
o := t[1]
|
||||
if o != "" {
|
||||
c := o[len(o)-1]
|
||||
switch c {
|
||||
case 'k', 'K', 'm', 'M', 'g', 'G', 't', 'T':
|
||||
uri.Unit = strings.ToLower(o[len(o)-1:])
|
||||
o = o[:len(o)-1]
|
||||
}
|
||||
}
|
||||
uri.MaxSize, err = strconv.ParseUint(o, 10, 64)
|
||||
if err != nil {
|
||||
p.xerrorf("parsing max size for uri: %s", err)
|
||||
}
|
||||
}
|
||||
return uri
|
||||
}
|
||||
|
||||
func (p *parser) xnumber() int {
|
||||
digits := p.xdigits()
|
||||
v, err := strconv.Atoi(digits)
|
||||
if err != nil {
|
||||
p.xerrorf("parsing %q: %s", digits, err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (p *parser) xkeyword() string {
|
||||
// ../rfc/7489:1195, keyword is imported from smtp.
|
||||
// ../rfc/5321:2287
|
||||
n := len(p.s) - p.o
|
||||
return p.xtakefn1case(func(b byte, i int) bool {
|
||||
return isalphadigit(b) || (b == '-' && i < n-1 && isalphadigit(p.s[p.o+i+1]))
|
||||
})
|
||||
}
|
||||
|
||||
func isdigit(b byte) bool {
|
||||
return b >= '0' && b <= '9'
|
||||
}
|
||||
|
||||
func isalpha(b byte) bool {
|
||||
return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z'
|
||||
}
|
||||
|
||||
func isalphadigit(b byte) bool {
|
||||
return isdigit(b) || isalpha(b)
|
||||
}
|
142
dmarc/parse_test.go
Normal file
142
dmarc/parse_test.go
Normal file
@ -0,0 +1,142 @@
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
// ../rfc/7489:3224
|
||||
|
||||
// bad cases
|
||||
bad := func(s string) {
|
||||
t.Helper()
|
||||
_, _, err := ParseRecord(s)
|
||||
if err == nil {
|
||||
t.Fatalf("got parse success, expected error")
|
||||
}
|
||||
}
|
||||
bad("")
|
||||
bad("v=")
|
||||
bad("v=DMARC12") // "2" leftover
|
||||
bad("v=DMARC1") // semicolon required
|
||||
bad("v=dmarc1; p=none") // dmarc1 is case-sensitive
|
||||
bad("v=DMARC1 p=none") // missing ;
|
||||
bad("v=DMARC1;") // missing p, no rua
|
||||
bad("v=DMARC1; sp=invalid") // invalid sp, no rua
|
||||
bad("v=DMARC1; sp=reject; p=reject") // p must be directly after v
|
||||
bad("v=DMARC1; p=none; p=none") // dup
|
||||
bad("v=DMARC1; p=none; p=reject") // dup
|
||||
bad("v=DMARC1;;") // missing tag
|
||||
bad("v=DMARC1; adkim=x") // bad value
|
||||
bad("v=DMARC1; aspf=123") // bad value
|
||||
bad("v=DMARC1; ri=") // missing value
|
||||
bad("v=DMARC1; ri=-1") // invalid, must be >= 0
|
||||
bad("v=DMARC1; ri=99999999999999999999999999999999999999") // does not fit in int
|
||||
bad("v=DMARC1; ri=123bad") // leftover data
|
||||
bad("v=DMARC1; ri=bad") // not a number
|
||||
bad("v=DMARC1; fo=")
|
||||
bad("v=DMARC1; fo=01")
|
||||
bad("v=DMARC1; fo=bad")
|
||||
bad("v=DMARC1; rf=bad-trailing-dash-")
|
||||
bad("v=DMARC1; rf=")
|
||||
bad("v=DMARC1; rf=bad.non-alphadigitdash")
|
||||
bad("v=DMARC1; p=badvalue")
|
||||
bad("v=DMARC1; sp=bad")
|
||||
bad("v=DMARC1; pct=110")
|
||||
bad("v=DMARC1; pct=bogus")
|
||||
bad("v=DMARC1; pct=")
|
||||
bad("v=DMARC1; rua=")
|
||||
bad("v=DMARC1; rua=bogus")
|
||||
bad("v=DMARC1; rua=mailto:dmarc-feedback@example.com!")
|
||||
bad("v=DMARC1; rua=mailto:dmarc-feedback@example.com!99999999999999999999999999999999999999999999999")
|
||||
bad("v=DMARC1; rua=mailto:dmarc-feedback@example.com!10p")
|
||||
|
||||
valid := func(s string, exp Record) {
|
||||
t.Helper()
|
||||
|
||||
r, _, err := ParseRecord(s)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if !reflect.DeepEqual(r, &exp) {
|
||||
t.Fatalf("got:\n%#v\nexpected:\n%#v", r, &exp)
|
||||
}
|
||||
}
|
||||
|
||||
// Return a record with default values, and overrides from r. Only for the fields used below.
|
||||
record := func(r Record) Record {
|
||||
rr := DefaultRecord
|
||||
if r.Policy != "" {
|
||||
rr.Policy = r.Policy
|
||||
}
|
||||
if r.AggregateReportAddresses != nil {
|
||||
rr.AggregateReportAddresses = r.AggregateReportAddresses
|
||||
}
|
||||
if r.FailureReportAddresses != nil {
|
||||
rr.FailureReportAddresses = r.FailureReportAddresses
|
||||
}
|
||||
if r.Percentage != 0 {
|
||||
rr.Percentage = r.Percentage
|
||||
}
|
||||
return rr
|
||||
}
|
||||
|
||||
valid("v=DMARC1; rua=mailto:mjl@mox.example", record(Record{
|
||||
Policy: "none",
|
||||
AggregateReportAddresses: []URI{
|
||||
{Address: "mailto:mjl@mox.example"},
|
||||
},
|
||||
})) // ../rfc/7489:1407
|
||||
valid("v=DMARC1; p=reject; sp=invalid; rua=mailto:mjl@mox.example", record(Record{
|
||||
Policy: "none",
|
||||
AggregateReportAddresses: []URI{
|
||||
{Address: "mailto:mjl@mox.example"},
|
||||
},
|
||||
})) // ../rfc/7489:1407
|
||||
valid("v=DMARC1; p=none; rua=mailto:dmarc-feedback@example.com", record(Record{
|
||||
Policy: "none",
|
||||
AggregateReportAddresses: []URI{
|
||||
{Address: "mailto:dmarc-feedback@example.com"},
|
||||
},
|
||||
}))
|
||||
valid("v=DMARC1; p=none; rua=mailto:dmarc-feedback@example.com;ruf=mailto:auth-reports@example.com", record(Record{
|
||||
Policy: "none",
|
||||
AggregateReportAddresses: []URI{
|
||||
{Address: "mailto:dmarc-feedback@example.com"},
|
||||
},
|
||||
FailureReportAddresses: []URI{
|
||||
{Address: "mailto:auth-reports@example.com"},
|
||||
},
|
||||
}))
|
||||
valid("v=DMARC1; p=quarantine; rua=mailto:dmarc-feedback@example.com,mailto:tld-test@thirdparty.example.net!10m; pct=25", record(Record{
|
||||
Policy: "quarantine",
|
||||
AggregateReportAddresses: []URI{
|
||||
{Address: "mailto:dmarc-feedback@example.com"},
|
||||
{Address: "mailto:tld-test@thirdparty.example.net", MaxSize: 10, Unit: "m"},
|
||||
},
|
||||
Percentage: 25,
|
||||
}))
|
||||
|
||||
valid("V = DMARC1 ; P = reject ;\tSP=none; unknown \t=\t ignored-future-value \t ; adkim=s; aspf=s; rua=mailto:dmarc-feedback@example.com ,\t\tmailto:tld-test@thirdparty.example.net!10m; RUF=mailto:auth-reports@example.com ,\t\tmailto:tld-test@thirdparty.example.net!0G; RI = 123; FO = 0:1:d:s ; RF= afrf : other; Pct = 0",
|
||||
Record{
|
||||
Version: "DMARC1",
|
||||
Policy: "reject",
|
||||
SubdomainPolicy: "none",
|
||||
ADKIM: "s",
|
||||
ASPF: "s",
|
||||
AggregateReportAddresses: []URI{
|
||||
{Address: "mailto:dmarc-feedback@example.com"},
|
||||
{Address: "mailto:tld-test@thirdparty.example.net", MaxSize: 10, Unit: "m"},
|
||||
},
|
||||
FailureReportAddresses: []URI{
|
||||
{Address: "mailto:auth-reports@example.com"},
|
||||
{Address: "mailto:tld-test@thirdparty.example.net", MaxSize: 0, Unit: "g"},
|
||||
},
|
||||
AggregateReportingInterval: 123,
|
||||
FailureReportingOptions: []string{"0", "1", "d", "s"},
|
||||
ReportingFormat: []string{"afrf", "other"},
|
||||
Percentage: 0,
|
||||
},
|
||||
)
|
||||
}
|
127
dmarc/txt.go
Normal file
127
dmarc/txt.go
Normal file
@ -0,0 +1,127 @@
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// todo: DMARCPolicy should be named just Policy, but this is causing conflicting types in sherpadoc output. should somehow get the dmarc-prefix only in the sherpadoc.
|
||||
|
||||
// Policy as used in DMARC DNS record for "p=" or "sp=".
|
||||
type DMARCPolicy string
|
||||
|
||||
// ../rfc/7489:1157
|
||||
|
||||
const (
|
||||
PolicyEmpty DMARCPolicy = "" // Only for the optional Record.SubdomainPolicy.
|
||||
PolicyNone DMARCPolicy = "none"
|
||||
PolicyQuarantine DMARCPolicy = "quarantine"
|
||||
PolicyReject DMARCPolicy = "reject"
|
||||
)
|
||||
|
||||
// URI is a destination address for reporting.
|
||||
type URI struct {
|
||||
Address string // Should start with "mailto:".
|
||||
MaxSize uint64 // Optional maximum message size, subject to Unit.
|
||||
Unit string // "" (b), "k", "g", "t" (case insensitive), unit size, where k is 2^10 etc.
|
||||
}
|
||||
|
||||
// String returns a string representation of the URI for inclusion in a DMARC
|
||||
// record.
|
||||
func (u URI) String() string {
|
||||
s := u.Address
|
||||
s = strings.ReplaceAll(s, ",", "%2C")
|
||||
s = strings.ReplaceAll(s, "!", "%21")
|
||||
if u.MaxSize > 0 {
|
||||
s += fmt.Sprintf("%d", u.MaxSize)
|
||||
}
|
||||
s += u.Unit
|
||||
return s
|
||||
}
|
||||
|
||||
// ../rfc/7489:1127
|
||||
|
||||
// Align specifies the required alignment of a domain name.
|
||||
type Align string
|
||||
|
||||
const (
|
||||
AlignStrict Align = "s" // Strict requires an exact domain name match.
|
||||
AlignRelaxed Align = "r" // Relaxed requires either an exact or subdomain name match.
|
||||
)
|
||||
|
||||
// Record is a DNS policy or reporting record.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// v=DMARC1; p=reject; rua=mailto:postmaster@mox.example
|
||||
type Record struct {
|
||||
Version string // "v=DMARC1"
|
||||
Policy DMARCPolicy // Required, for "p=".
|
||||
SubdomainPolicy DMARCPolicy // Like policy but for subdomains. Optional, for "sp=".
|
||||
AggregateReportAddresses []URI // Optional, for "rua=".
|
||||
FailureReportAddresses []URI // Optional, for "ruf="
|
||||
ADKIM Align // "r" (default) for relaxed or "s" for simple. For "adkim=".
|
||||
ASPF Align // "r" (default) for relaxed or "s" for simple. For "aspf=".
|
||||
AggregateReportingInterval int // Default 86400. For "ri="
|
||||
FailureReportingOptions []string // "0" (default), "1", "d", "s". For "fo=".
|
||||
ReportingFormat []string // "afrf" (default). Ffor "rf=".
|
||||
Percentage int // Between 0 and 100, default 100. For "pct=".
|
||||
}
|
||||
|
||||
// DefaultRecord holds the defaults for a DMARC record.
|
||||
var DefaultRecord = Record{
|
||||
Version: "DMARC1",
|
||||
ADKIM: "r",
|
||||
ASPF: "r",
|
||||
AggregateReportingInterval: 86400,
|
||||
FailureReportingOptions: []string{"0"},
|
||||
ReportingFormat: []string{"afrf"},
|
||||
Percentage: 100,
|
||||
}
|
||||
|
||||
// String returns the DMARC record for use as DNS TXT record.
|
||||
func (r Record) String() string {
|
||||
b := &strings.Builder{}
|
||||
b.WriteString("v=" + r.Version)
|
||||
|
||||
wrote := false
|
||||
write := func(do bool, tag, value string) {
|
||||
if do {
|
||||
fmt.Fprintf(b, ";%s=%s", tag, value)
|
||||
wrote = true
|
||||
}
|
||||
}
|
||||
write(r.Policy != "", "p", string(r.Policy))
|
||||
write(r.SubdomainPolicy != "", "sp", string(r.SubdomainPolicy))
|
||||
if len(r.AggregateReportAddresses) > 0 {
|
||||
l := make([]string, len(r.AggregateReportAddresses))
|
||||
for i, a := range r.AggregateReportAddresses {
|
||||
l[i] = a.String()
|
||||
}
|
||||
s := strings.Join(l, ",")
|
||||
write(true, "rua", s)
|
||||
}
|
||||
if len(r.FailureReportAddresses) > 0 {
|
||||
l := make([]string, len(r.FailureReportAddresses))
|
||||
for i, a := range r.FailureReportAddresses {
|
||||
l[i] = a.String()
|
||||
}
|
||||
s := strings.Join(l, ",")
|
||||
write(true, "ruf", s)
|
||||
}
|
||||
write(r.ADKIM != "", "adkim", string(r.ADKIM))
|
||||
write(r.ASPF != "", "aspf", string(r.ASPF))
|
||||
write(r.AggregateReportingInterval != DefaultRecord.AggregateReportingInterval, "ri", fmt.Sprintf("%d", r.AggregateReportingInterval))
|
||||
if len(r.FailureReportingOptions) > 1 || (len(r.FailureReportingOptions) == 1 && r.FailureReportingOptions[0] != "0") {
|
||||
write(true, "fo", strings.Join(r.FailureReportingOptions, ":"))
|
||||
}
|
||||
if len(r.ReportingFormat) > 1 || (len(r.ReportingFormat) == 1 && strings.EqualFold(r.ReportingFormat[0], "afrf")) {
|
||||
write(true, "rf", strings.Join(r.FailureReportingOptions, ":"))
|
||||
}
|
||||
write(r.Percentage != 100, "pct", fmt.Sprintf("%d", r.Percentage))
|
||||
|
||||
if !wrote {
|
||||
b.WriteString(";")
|
||||
}
|
||||
return b.String()
|
||||
}
|
Reference in New Issue
Block a user