This commit is contained in:
Mechiel Lukkien
2023-01-30 14:27:06 +01:00
commit cb229cb6cf
1256 changed files with 491723 additions and 0 deletions

239
dmarc/dmarc.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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()
}