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