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}, ) }