mirror of
https://github.com/mjl-/mox.git
synced 2024-12-25 16:03:48 +03:00
5b20cba50a
we don't want external software to include internal details like mlog. slog.Logger is/will be the standard. we still have mlog for its helper functions, and its handler that logs in concise logfmt used by mox. packages that are not meant for reuse still pass around mlog.Log for convenience. we use golang.org/x/exp/slog because we also support the previous Go toolchain version. with the next Go release, we'll switch to the builtin slog.
317 lines
10 KiB
Go
317 lines
10 KiB
Go
package dmarc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/mjl-/mox/dkim"
|
|
"github.com/mjl-/mox/dns"
|
|
"github.com/mjl-/mox/mlog"
|
|
"github.com/mjl-/mox/spf"
|
|
)
|
|
|
|
var pkglog = mlog.New("dmarc", nil)
|
|
|
|
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: []string{
|
|
"txt _dmarc.temperror.example.",
|
|
},
|
|
}
|
|
|
|
test := func(d string, expStatus Status, expDomain string, expRecord *Record, expErr error) {
|
|
t.Helper()
|
|
|
|
status, dom, record, _, _, err := Lookup(context.Background(), pkglog.Logger, 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 TestLookupExternalReportsAccepted(t *testing.T) {
|
|
resolver := dns.MockResolver{
|
|
TXT: map[string][]string{
|
|
"example.com._report._dmarc.simple.example.": {"v=DMARC1"},
|
|
"example.com._report._dmarc.simple2.example.": {"v=DMARC1;"},
|
|
"example.com._report._dmarc.one.example.": {"v=DMARC1; p=none;", "other"},
|
|
"example.com._report._dmarc.temperror.example.": {"v=DMARC1; p=none;"},
|
|
"example.com._report._dmarc.multiple.example.": {"v=DMARC1; p=none;", "v=DMARC1"},
|
|
"example.com._report._dmarc.malformed.example.": {"v=DMARC1; p=none; bogus;"},
|
|
},
|
|
Fail: []string{
|
|
"txt example.com._report._dmarc.temperror.example.",
|
|
},
|
|
}
|
|
|
|
test := func(dom, extdom string, expStatus Status, expAccepts bool, expErr error) {
|
|
t.Helper()
|
|
|
|
accepts, status, _, _, _, err := LookupExternalReportsAccepted(context.Background(), pkglog.Logger, resolver, dns.Domain{ASCII: dom}, dns.Domain{ASCII: extdom})
|
|
if (err == nil) != (expErr == nil) || err != nil && !errors.Is(err, expErr) {
|
|
t.Fatalf("got err %#v, expected %#v", err, expErr)
|
|
}
|
|
if status != expStatus || accepts != expAccepts {
|
|
t.Fatalf("got status %s, accepts %v, expected %v, %v", status, accepts, expStatus, expAccepts)
|
|
}
|
|
}
|
|
|
|
r := DefaultRecord
|
|
r.Policy = PolicyNone
|
|
test("example.com", "simple.example", StatusNone, true, nil)
|
|
test("example.org", "simple.example", StatusNone, false, ErrNoRecord)
|
|
test("example.com", "simple2.example", StatusNone, true, nil)
|
|
test("example.com", "one.example", StatusNone, true, nil)
|
|
test("example.com", "absent.example", StatusNone, false, ErrNoRecord)
|
|
test("example.com", "multiple.example", StatusNone, true, nil)
|
|
test("example.com", "malformed.example", StatusPermerror, false, ErrSyntax)
|
|
test("example.com", "temperror.example", StatusTemperror, false, ErrDNS)
|
|
}
|
|
|
|
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: []string{
|
|
"txt _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(), pkglog.Logger, 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, false, false, dns.Domain{ASCII: "reject.example"}, &reject, false, nil},
|
|
)
|
|
|
|
// Accept with spf pass.
|
|
test("reject.example",
|
|
[]dkim.Result{},
|
|
spf.StatusPass,
|
|
&dns.Domain{ASCII: "sub.reject.example"},
|
|
true, Result{false, StatusPass, true, false, dns.Domain{ASCII: "reject.example"}, &reject, false, 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, false, true, dns.Domain{ASCII: "reject.example"}, &reject, false, 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, false, false, dns.Domain{ASCII: "strict.example"}, &strict, false, nil},
|
|
)
|
|
|
|
// No dmarc policy, nothing to say.
|
|
test("absent.example",
|
|
[]dkim.Result{},
|
|
spf.StatusNone,
|
|
nil,
|
|
false, Result{false, StatusNone, false, false, dns.Domain{ASCII: "absent.example"}, nil, false, ErrNoRecord},
|
|
)
|
|
|
|
// No dmarc policy, spf pass does nothing.
|
|
test("absent.example",
|
|
[]dkim.Result{},
|
|
spf.StatusPass,
|
|
&dns.Domain{ASCII: "absent.example"},
|
|
false, Result{false, StatusNone, false, false, dns.Domain{ASCII: "absent.example"}, nil, false, 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, true, false, dns.Domain{ASCII: "none.example"}, &none, false, 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, false, false, dns.Domain{ASCII: "test.example"}, &testr, false, 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, false, false, dns.Domain{ASCII: "subnone.example"}, &sub, false, 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, false, false, dns.Domain{ASCII: "reject.example"}, &reject, false, 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, false, false, dns.Domain{ASCII: "reject.example"}, &reject, false, 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, false, true, dns.Domain{ASCII: "reject.example"}, &reject, false, 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, true, false, dns.Domain{ASCII: "reject.example"}, &reject, false, nil},
|
|
)
|
|
|
|
// Bad DMARC record results in permerror without reject.
|
|
test("malformed.example",
|
|
[]dkim.Result{},
|
|
spf.StatusNone,
|
|
nil,
|
|
false, Result{false, StatusPermerror, false, false, dns.Domain{ASCII: "malformed.example"}, nil, false, 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, false, false, dns.Domain{ASCII: "example.com"}, &reject, false, nil},
|
|
)
|
|
}
|