1package dmarc
2
3import (
4 "context"
5 "errors"
6 "reflect"
7 "testing"
8
9 "github.com/mjl-/mox/dkim"
10 "github.com/mjl-/mox/dns"
11 "github.com/mjl-/mox/mlog"
12 "github.com/mjl-/mox/spf"
13)
14
15var pkglog = mlog.New("dmarc", nil)
16
17func TestLookup(t *testing.T) {
18 resolver := dns.MockResolver{
19 TXT: map[string][]string{
20 "_dmarc.simple.example.": {"v=DMARC1; p=none;"},
21 "_dmarc.one.example.": {"v=DMARC1; p=none;", "other"},
22 "_dmarc.temperror.example.": {"v=DMARC1; p=none;"},
23 "_dmarc.multiple.example.": {"v=DMARC1; p=none;", "v=DMARC1; p=none;"},
24 "_dmarc.malformed.example.": {"v=DMARC1; p=none; bogus;"},
25 "_dmarc.example.com.": {"v=DMARC1; p=none;"},
26 },
27 Fail: []string{
28 "txt _dmarc.temperror.example.",
29 },
30 }
31
32 test := func(d string, expStatus Status, expDomain string, expRecord *Record, expErr error) {
33 t.Helper()
34
35 status, dom, record, _, _, err := Lookup(context.Background(), pkglog.Logger, resolver, dns.Domain{ASCII: d})
36 if (err == nil) != (expErr == nil) || err != nil && !errors.Is(err, expErr) {
37 t.Fatalf("got err %#v, expected %#v", err, expErr)
38 }
39 expd := dns.Domain{ASCII: expDomain}
40 if status != expStatus || dom != expd || !reflect.DeepEqual(record, expRecord) {
41 t.Fatalf("got status %v, dom %v, record %#v, expected %v %v %#v", status, dom, record, expStatus, expDomain, expRecord)
42 }
43 }
44
45 r := DefaultRecord
46 r.Policy = PolicyNone
47 test("simple.example", StatusNone, "simple.example", &r, nil)
48 test("one.example", StatusNone, "one.example", &r, nil)
49 test("absent.example", StatusNone, "absent.example", nil, ErrNoRecord)
50 test("multiple.example", StatusNone, "multiple.example", nil, ErrMultipleRecords)
51 test("malformed.example", StatusPermerror, "malformed.example", nil, ErrSyntax)
52 test("temperror.example", StatusTemperror, "temperror.example", nil, ErrDNS)
53 test("sub.example.com", StatusNone, "example.com", &r, nil) // Policy published at organizational domain, public suffix.
54}
55
56func TestLookupExternalReportsAccepted(t *testing.T) {
57 resolver := dns.MockResolver{
58 TXT: map[string][]string{
59 "example.com._report._dmarc.simple.example.": {"v=DMARC1"},
60 "example.com._report._dmarc.simple2.example.": {"v=DMARC1;"},
61 "example.com._report._dmarc.one.example.": {"v=DMARC1; p=none;", "other"},
62 "example.com._report._dmarc.temperror.example.": {"v=DMARC1; p=none;"},
63 "example.com._report._dmarc.multiple.example.": {"v=DMARC1; p=none;", "v=DMARC1"},
64 "example.com._report._dmarc.malformed.example.": {"v=DMARC1; p=none; bogus;"},
65 },
66 Fail: []string{
67 "txt example.com._report._dmarc.temperror.example.",
68 },
69 }
70
71 test := func(dom, extdom string, expStatus Status, expAccepts bool, expErr error) {
72 t.Helper()
73
74 accepts, status, _, _, _, err := LookupExternalReportsAccepted(context.Background(), pkglog.Logger, resolver, dns.Domain{ASCII: dom}, dns.Domain{ASCII: extdom})
75 if (err == nil) != (expErr == nil) || err != nil && !errors.Is(err, expErr) {
76 t.Fatalf("got err %#v, expected %#v", err, expErr)
77 }
78 if status != expStatus || accepts != expAccepts {
79 t.Fatalf("got status %s, accepts %v, expected %v, %v", status, accepts, expStatus, expAccepts)
80 }
81 }
82
83 r := DefaultRecord
84 r.Policy = PolicyNone
85 test("example.com", "simple.example", StatusNone, true, nil)
86 test("example.org", "simple.example", StatusNone, false, ErrNoRecord)
87 test("example.com", "simple2.example", StatusNone, true, nil)
88 test("example.com", "one.example", StatusNone, true, nil)
89 test("example.com", "absent.example", StatusNone, false, ErrNoRecord)
90 test("example.com", "multiple.example", StatusNone, true, nil)
91 test("example.com", "malformed.example", StatusPermerror, false, ErrSyntax)
92 test("example.com", "temperror.example", StatusTemperror, false, ErrDNS)
93}
94
95func TestVerify(t *testing.T) {
96 resolver := dns.MockResolver{
97 TXT: map[string][]string{
98 "_dmarc.reject.example.": {"v=DMARC1; p=reject"},
99 "_dmarc.strict.example.": {"v=DMARC1; p=reject; adkim=s; aspf=s"},
100 "_dmarc.test.example.": {"v=DMARC1; p=reject; pct=0"},
101 "_dmarc.subnone.example.": {"v=DMARC1; p=reject; sp=none"},
102 "_dmarc.none.example.": {"v=DMARC1; p=none"},
103 "_dmarc.malformed.example.": {"v=DMARC1; p=none; bogus"},
104 "_dmarc.example.com.": {"v=DMARC1; p=reject"},
105 },
106 Fail: []string{
107 "txt _dmarc.temperror.example.",
108 },
109 }
110
111 equalResult := func(got, exp Result) bool {
112 if reflect.DeepEqual(got, exp) {
113 return true
114 }
115 if got.Err != nil && exp.Err != nil && (got.Err == exp.Err || errors.Is(got.Err, exp.Err)) {
116 got.Err = nil
117 exp.Err = nil
118 return reflect.DeepEqual(got, exp)
119 }
120 return false
121 }
122
123 test := func(fromDom string, dkimResults []dkim.Result, spfResult spf.Status, spfIdentity *dns.Domain, expUseResult bool, expResult Result) {
124 t.Helper()
125
126 from, err := dns.ParseDomain(fromDom)
127 if err != nil {
128 t.Fatalf("parsing domain: %v", err)
129 }
130 useResult, result := Verify(context.Background(), pkglog.Logger, resolver, from, dkimResults, spfResult, spfIdentity, true)
131 if useResult != expUseResult || !equalResult(result, expResult) {
132 t.Fatalf("verify: got useResult %v, result %#v, expected %v %#v", useResult, result, expUseResult, expResult)
133 }
134 }
135
136 // Basic case, reject policy and no dkim or spf results.
137 reject := DefaultRecord
138 reject.Policy = PolicyReject
139 test("reject.example",
140 []dkim.Result{},
141 spf.StatusNone,
142 nil,
143 true, Result{true, StatusFail, false, false, dns.Domain{ASCII: "reject.example"}, &reject, false, nil},
144 )
145
146 // Accept with spf pass.
147 test("reject.example",
148 []dkim.Result{},
149 spf.StatusPass,
150 &dns.Domain{ASCII: "sub.reject.example"},
151 true, Result{false, StatusPass, true, false, dns.Domain{ASCII: "reject.example"}, &reject, false, nil},
152 )
153
154 // Accept with dkim pass.
155 test("reject.example",
156 []dkim.Result{
157 {
158 Status: dkim.StatusPass,
159 Sig: &dkim.Sig{ // Just the minimum fields needed.
160 Domain: dns.Domain{ASCII: "sub.reject.example"},
161 },
162 Record: &dkim.Record{},
163 },
164 },
165 spf.StatusFail,
166 &dns.Domain{ASCII: "reject.example"},
167 true, Result{false, StatusPass, false, true, dns.Domain{ASCII: "reject.example"}, &reject, false, nil},
168 )
169
170 // Reject due to spf and dkim "strict".
171 strict := DefaultRecord
172 strict.Policy = PolicyReject
173 strict.ADKIM = AlignStrict
174 strict.ASPF = AlignStrict
175 test("strict.example",
176 []dkim.Result{
177 {
178 Status: dkim.StatusPass,
179 Sig: &dkim.Sig{ // Just the minimum fields needed.
180 Domain: dns.Domain{ASCII: "sub.strict.example"},
181 },
182 Record: &dkim.Record{},
183 },
184 },
185 spf.StatusPass,
186 &dns.Domain{ASCII: "sub.strict.example"},
187 true, Result{true, StatusFail, false, false, dns.Domain{ASCII: "strict.example"}, &strict, false, nil},
188 )
189
190 // No dmarc policy, nothing to say.
191 test("absent.example",
192 []dkim.Result{},
193 spf.StatusNone,
194 nil,
195 false, Result{false, StatusNone, false, false, dns.Domain{ASCII: "absent.example"}, nil, false, ErrNoRecord},
196 )
197
198 // No dmarc policy, spf pass does nothing.
199 test("absent.example",
200 []dkim.Result{},
201 spf.StatusPass,
202 &dns.Domain{ASCII: "absent.example"},
203 false, Result{false, StatusNone, false, false, dns.Domain{ASCII: "absent.example"}, nil, false, ErrNoRecord},
204 )
205
206 none := DefaultRecord
207 none.Policy = PolicyNone
208 // Policy none results in no reject.
209 test("none.example",
210 []dkim.Result{},
211 spf.StatusPass,
212 &dns.Domain{ASCII: "none.example"},
213 true, Result{false, StatusPass, true, false, dns.Domain{ASCII: "none.example"}, &none, false, nil},
214 )
215
216 // No actual reject due to pct=0.
217 testr := DefaultRecord
218 testr.Policy = PolicyReject
219 testr.Percentage = 0
220 test("test.example",
221 []dkim.Result{},
222 spf.StatusNone,
223 nil,
224 false, Result{true, StatusFail, false, false, dns.Domain{ASCII: "test.example"}, &testr, false, nil},
225 )
226
227 // No reject if subdomain has "none" policy.
228 sub := DefaultRecord
229 sub.Policy = PolicyReject
230 sub.SubdomainPolicy = PolicyNone
231 test("sub.subnone.example",
232 []dkim.Result{},
233 spf.StatusFail,
234 &dns.Domain{ASCII: "sub.subnone.example"},
235 true, Result{false, StatusFail, false, false, dns.Domain{ASCII: "subnone.example"}, &sub, false, nil},
236 )
237
238 // No reject if spf temperror and no other pass.
239 test("reject.example",
240 []dkim.Result{},
241 spf.StatusTemperror,
242 &dns.Domain{ASCII: "mail.reject.example"},
243 true, Result{false, StatusTemperror, false, false, dns.Domain{ASCII: "reject.example"}, &reject, false, nil},
244 )
245
246 // No reject if dkim temperror and no other pass.
247 test("reject.example",
248 []dkim.Result{
249 {
250 Status: dkim.StatusTemperror,
251 Sig: &dkim.Sig{ // Just the minimum fields needed.
252 Domain: dns.Domain{ASCII: "sub.reject.example"},
253 },
254 Record: &dkim.Record{},
255 },
256 },
257 spf.StatusNone,
258 nil,
259 true, Result{false, StatusTemperror, false, false, dns.Domain{ASCII: "reject.example"}, &reject, false, nil},
260 )
261
262 // No reject if spf temperror but still dkim pass.
263 test("reject.example",
264 []dkim.Result{
265 {
266 Status: dkim.StatusPass,
267 Sig: &dkim.Sig{ // Just the minimum fields needed.
268 Domain: dns.Domain{ASCII: "sub.reject.example"},
269 },
270 Record: &dkim.Record{},
271 },
272 },
273 spf.StatusTemperror,
274 &dns.Domain{ASCII: "mail.reject.example"},
275 true, Result{false, StatusPass, false, true, dns.Domain{ASCII: "reject.example"}, &reject, false, nil},
276 )
277
278 // No reject if dkim temperror but still spf pass.
279 test("reject.example",
280 []dkim.Result{
281 {
282 Status: dkim.StatusTemperror,
283 Sig: &dkim.Sig{ // Just the minimum fields needed.
284 Domain: dns.Domain{ASCII: "sub.reject.example"},
285 },
286 Record: &dkim.Record{},
287 },
288 },
289 spf.StatusPass,
290 &dns.Domain{ASCII: "mail.reject.example"},
291 true, Result{false, StatusPass, true, false, dns.Domain{ASCII: "reject.example"}, &reject, false, nil},
292 )
293
294 // Bad DMARC record results in permerror without reject.
295 test("malformed.example",
296 []dkim.Result{},
297 spf.StatusNone,
298 nil,
299 false, Result{false, StatusPermerror, false, false, dns.Domain{ASCII: "malformed.example"}, nil, false, ErrSyntax},
300 )
301
302 // DKIM domain that is higher-level than organizational can not result in a pass. ../rfc/7489:525
303 test("example.com",
304 []dkim.Result{
305 {
306 Status: dkim.StatusPass,
307 Sig: &dkim.Sig{ // Just the minimum fields needed.
308 Domain: dns.Domain{ASCII: "com"},
309 },
310 Record: &dkim.Record{},
311 },
312 },
313 spf.StatusNone,
314 nil,
315 true, Result{true, StatusFail, false, false, dns.Domain{ASCII: "example.com"}, &reject, false, nil},
316 )
317}
318