2023-01-30 16:27:06 +03:00
|
|
|
package dmarc
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"errors"
|
|
|
|
"reflect"
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
"github.com/mjl-/mox/dkim"
|
|
|
|
"github.com/mjl-/mox/dns"
|
2023-12-05 15:35:58 +03:00
|
|
|
"github.com/mjl-/mox/mlog"
|
2023-01-30 16:27:06 +03:00
|
|
|
"github.com/mjl-/mox/spf"
|
|
|
|
)
|
|
|
|
|
2023-12-05 15:35:58 +03:00
|
|
|
var pkglog = mlog.New("dmarc", nil)
|
|
|
|
|
2023-01-30 16:27:06 +03:00
|
|
|
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;"},
|
|
|
|
},
|
2023-10-12 17:07:25 +03:00
|
|
|
Fail: []string{
|
|
|
|
"txt _dmarc.temperror.example.",
|
2023-01-30 16:27:06 +03:00
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
test := func(d string, expStatus Status, expDomain string, expRecord *Record, expErr error) {
|
|
|
|
t.Helper()
|
|
|
|
|
2023-12-05 15:35:58 +03:00
|
|
|
status, dom, record, _, _, err := Lookup(context.Background(), pkglog.Logger, resolver, dns.Domain{ASCII: d})
|
2023-01-30 16:27:06 +03:00
|
|
|
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.
|
|
|
|
}
|
|
|
|
|
2023-08-23 15:27:21 +03:00
|
|
|
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;"},
|
|
|
|
},
|
2023-10-12 17:07:25 +03:00
|
|
|
Fail: []string{
|
|
|
|
"txt example.com._report._dmarc.temperror.example.",
|
2023-08-23 15:27:21 +03:00
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
test := func(dom, extdom string, expStatus Status, expAccepts bool, expErr error) {
|
|
|
|
t.Helper()
|
|
|
|
|
2023-12-05 15:35:58 +03:00
|
|
|
accepts, status, _, _, _, err := LookupExternalReportsAccepted(context.Background(), pkglog.Logger, resolver, dns.Domain{ASCII: dom}, dns.Domain{ASCII: extdom})
|
2023-08-23 15:27:21 +03:00
|
|
|
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)
|
2023-11-01 19:55:40 +03:00
|
|
|
test("example.com", "multiple.example", StatusNone, true, nil)
|
2023-08-23 15:27:21 +03:00
|
|
|
test("example.com", "malformed.example", StatusPermerror, false, ErrSyntax)
|
|
|
|
test("example.com", "temperror.example", StatusTemperror, false, ErrDNS)
|
|
|
|
}
|
|
|
|
|
2023-01-30 16:27:06 +03:00
|
|
|
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"},
|
|
|
|
},
|
2023-10-12 17:07:25 +03:00
|
|
|
Fail: []string{
|
|
|
|
"txt _dmarc.temperror.example.",
|
2023-01-30 16:27:06 +03:00
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
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)
|
|
|
|
}
|
2023-12-05 15:35:58 +03:00
|
|
|
useResult, result := Verify(context.Background(), pkglog.Logger, resolver, from, dkimResults, spfResult, spfIdentity, true)
|
2023-01-30 16:27:06 +03:00
|
|
|
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,
|
2023-11-01 19:55:40 +03:00
|
|
|
true, Result{true, StatusFail, false, false, dns.Domain{ASCII: "reject.example"}, &reject, false, nil},
|
2023-01-30 16:27:06 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
// Accept with spf pass.
|
|
|
|
test("reject.example",
|
|
|
|
[]dkim.Result{},
|
|
|
|
spf.StatusPass,
|
|
|
|
&dns.Domain{ASCII: "sub.reject.example"},
|
2023-11-01 19:55:40 +03:00
|
|
|
true, Result{false, StatusPass, true, false, dns.Domain{ASCII: "reject.example"}, &reject, false, nil},
|
2023-01-30 16:27:06 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
// 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"},
|
2023-11-01 19:55:40 +03:00
|
|
|
true, Result{false, StatusPass, false, true, dns.Domain{ASCII: "reject.example"}, &reject, false, nil},
|
2023-01-30 16:27:06 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
// 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"},
|
2023-11-01 19:55:40 +03:00
|
|
|
true, Result{true, StatusFail, false, false, dns.Domain{ASCII: "strict.example"}, &strict, false, nil},
|
2023-01-30 16:27:06 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
// No dmarc policy, nothing to say.
|
|
|
|
test("absent.example",
|
|
|
|
[]dkim.Result{},
|
|
|
|
spf.StatusNone,
|
|
|
|
nil,
|
2023-11-01 19:55:40 +03:00
|
|
|
false, Result{false, StatusNone, false, false, dns.Domain{ASCII: "absent.example"}, nil, false, ErrNoRecord},
|
2023-01-30 16:27:06 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
// No dmarc policy, spf pass does nothing.
|
|
|
|
test("absent.example",
|
|
|
|
[]dkim.Result{},
|
|
|
|
spf.StatusPass,
|
|
|
|
&dns.Domain{ASCII: "absent.example"},
|
2023-11-01 19:55:40 +03:00
|
|
|
false, Result{false, StatusNone, false, false, dns.Domain{ASCII: "absent.example"}, nil, false, ErrNoRecord},
|
2023-01-30 16:27:06 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
none := DefaultRecord
|
|
|
|
none.Policy = PolicyNone
|
|
|
|
// Policy none results in no reject.
|
|
|
|
test("none.example",
|
|
|
|
[]dkim.Result{},
|
|
|
|
spf.StatusPass,
|
|
|
|
&dns.Domain{ASCII: "none.example"},
|
2023-11-01 19:55:40 +03:00
|
|
|
true, Result{false, StatusPass, true, false, dns.Domain{ASCII: "none.example"}, &none, false, nil},
|
2023-01-30 16:27:06 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
// No actual reject due to pct=0.
|
|
|
|
testr := DefaultRecord
|
|
|
|
testr.Policy = PolicyReject
|
|
|
|
testr.Percentage = 0
|
|
|
|
test("test.example",
|
|
|
|
[]dkim.Result{},
|
|
|
|
spf.StatusNone,
|
|
|
|
nil,
|
2023-11-01 19:55:40 +03:00
|
|
|
false, Result{true, StatusFail, false, false, dns.Domain{ASCII: "test.example"}, &testr, false, nil},
|
2023-01-30 16:27:06 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
// 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"},
|
2023-11-01 19:55:40 +03:00
|
|
|
true, Result{false, StatusFail, false, false, dns.Domain{ASCII: "subnone.example"}, &sub, false, nil},
|
2023-01-30 16:27:06 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
// No reject if spf temperror and no other pass.
|
|
|
|
test("reject.example",
|
|
|
|
[]dkim.Result{},
|
|
|
|
spf.StatusTemperror,
|
|
|
|
&dns.Domain{ASCII: "mail.reject.example"},
|
2023-11-01 19:55:40 +03:00
|
|
|
true, Result{false, StatusTemperror, false, false, dns.Domain{ASCII: "reject.example"}, &reject, false, nil},
|
2023-01-30 16:27:06 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
// 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,
|
2023-11-01 19:55:40 +03:00
|
|
|
true, Result{false, StatusTemperror, false, false, dns.Domain{ASCII: "reject.example"}, &reject, false, nil},
|
2023-01-30 16:27:06 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
// 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"},
|
2023-11-01 19:55:40 +03:00
|
|
|
true, Result{false, StatusPass, false, true, dns.Domain{ASCII: "reject.example"}, &reject, false, nil},
|
2023-01-30 16:27:06 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
// 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"},
|
2023-11-01 19:55:40 +03:00
|
|
|
true, Result{false, StatusPass, true, false, dns.Domain{ASCII: "reject.example"}, &reject, false, nil},
|
2023-01-30 16:27:06 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
// Bad DMARC record results in permerror without reject.
|
|
|
|
test("malformed.example",
|
|
|
|
[]dkim.Result{},
|
|
|
|
spf.StatusNone,
|
|
|
|
nil,
|
2023-11-01 19:55:40 +03:00
|
|
|
false, Result{false, StatusPermerror, false, false, dns.Domain{ASCII: "malformed.example"}, nil, false, ErrSyntax},
|
2023-01-30 16:27:06 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
// 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,
|
2023-11-01 19:55:40 +03:00
|
|
|
true, Result{true, StatusFail, false, false, dns.Domain{ASCII: "example.com"}, &reject, false, nil},
|
2023-01-30 16:27:06 +03:00
|
|
|
)
|
|
|
|
}
|