mail_auth/dmarc/
verify.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use super::{Alignment, Dmarc, URI};
8use crate::{
9    AuthenticatedMessage, DkimOutput, DkimResult, DmarcOutput, DmarcResult, Error, MX,
10    MessageAuthenticator, Parameters, ResolverCache, SpfOutput, SpfResult, Txt,
11    common::cache::NoCache,
12};
13use std::{
14    net::{IpAddr, Ipv4Addr, Ipv6Addr},
15    sync::Arc,
16};
17
18pub struct DmarcParameters<'x, F>
19where
20    F: for<'y> Fn(&'y str) -> &'y str,
21{
22    pub message: &'x AuthenticatedMessage<'x>,
23    pub dkim_output: &'x [DkimOutput<'x>],
24    pub rfc5321_mail_from_domain: &'x str,
25    pub spf_output: &'x SpfOutput,
26    pub domain_suffix_fn: F,
27}
28
29impl MessageAuthenticator {
30    /// Verifies the DMARC policy of an RFC5321.MailFrom domain
31    pub async fn verify_dmarc<'x, TXT, MXX, IPV4, IPV6, PTR, F>(
32        &self,
33        params: impl Into<Parameters<'x, DmarcParameters<'x, F>, TXT, MXX, IPV4, IPV6, PTR>>,
34    ) -> DmarcOutput
35    where
36        TXT: ResolverCache<String, Txt> + 'x,
37        MXX: ResolverCache<String, Arc<Vec<MX>>> + 'x,
38        IPV4: ResolverCache<String, Arc<Vec<Ipv4Addr>>> + 'x,
39        IPV6: ResolverCache<String, Arc<Vec<Ipv6Addr>>> + 'x,
40        PTR: ResolverCache<IpAddr, Arc<Vec<String>>> + 'x,
41        F: for<'y> Fn(&'y str) -> &'y str,
42    {
43        // Extract RFC5322.From domain
44        let params = params.into();
45        let message = params.params.message;
46        let dkim_output = params.params.dkim_output;
47        let domain_suffix_fn = params.params.domain_suffix_fn;
48        let rfc5321_mail_from_domain = params.params.rfc5321_mail_from_domain;
49        let spf_output = params.params.spf_output;
50        let mut rfc5322_from_domain = "";
51        for from in &message.from {
52            if let Some((_, domain)) = from.rsplit_once('@') {
53                if rfc5322_from_domain.is_empty() {
54                    rfc5322_from_domain = domain;
55                } else if rfc5322_from_domain != domain {
56                    // Multi-valued RFC5322.From header fields with multiple
57                    // domains MUST be exempt from DMARC checking.
58                    return DmarcOutput::default();
59                }
60            }
61        }
62        if rfc5322_from_domain.is_empty() {
63            return DmarcOutput::default();
64        }
65
66        // Obtain DMARC policy
67        let dmarc = match self
68            .dmarc_tree_walk(rfc5322_from_domain, params.cache_txt)
69            .await
70        {
71            Ok(Some(dmarc)) => dmarc,
72            Ok(None) => return DmarcOutput::default().with_domain(rfc5322_from_domain),
73            Err(err) => {
74                let err = DmarcResult::from(err);
75                return DmarcOutput::default()
76                    .with_domain(rfc5322_from_domain)
77                    .with_dkim_result(err.clone())
78                    .with_spf_result(err);
79            }
80        };
81
82        let mut output = DmarcOutput {
83            spf_result: DmarcResult::None,
84            dkim_result: DmarcResult::None,
85            domain: rfc5322_from_domain.to_string(),
86            policy: dmarc.p,
87            record: None,
88        };
89
90        let has_dkim_pass = dkim_output.iter().any(|o| o.result == DkimResult::Pass);
91        if spf_output.result == SpfResult::Pass || has_dkim_pass {
92            // Check SPF alignment
93            let rfc5322_from_subdomain = domain_suffix_fn(rfc5322_from_domain);
94            if spf_output.result == SpfResult::Pass {
95                output.spf_result = if rfc5321_mail_from_domain == rfc5322_from_domain {
96                    DmarcResult::Pass
97                } else if dmarc.aspf == Alignment::Relaxed
98                    && domain_suffix_fn(rfc5321_mail_from_domain) == rfc5322_from_subdomain
99                {
100                    output.policy = dmarc.sp;
101                    DmarcResult::Pass
102                } else {
103                    DmarcResult::Fail(Error::NotAligned)
104                };
105            }
106
107            // Check DKIM alignment
108            if has_dkim_pass {
109                output.dkim_result = if dkim_output.iter().any(|o| {
110                    o.result == DkimResult::Pass
111                        && o.signature.as_ref().unwrap().d.eq(rfc5322_from_domain)
112                }) {
113                    DmarcResult::Pass
114                } else if dmarc.adkim == Alignment::Relaxed
115                    && dkim_output.iter().any(|o| {
116                        o.result == DkimResult::Pass
117                            && domain_suffix_fn(&o.signature.as_ref().unwrap().d)
118                                == rfc5322_from_subdomain
119                    })
120                {
121                    output.policy = dmarc.sp;
122                    DmarcResult::Pass
123                } else {
124                    if dkim_output.iter().any(|o| {
125                        o.result == DkimResult::Pass
126                            && domain_suffix_fn(&o.signature.as_ref().unwrap().d)
127                                == rfc5322_from_subdomain
128                    }) {
129                        output.policy = dmarc.sp;
130                    }
131                    DmarcResult::Fail(Error::NotAligned)
132                };
133            }
134        }
135
136        output.with_record(dmarc)
137    }
138
139    /// Validates the external report e-mail addresses of a DMARC record
140    pub async fn verify_dmarc_report_address<'x>(
141        &self,
142        domain: &str,
143        addresses: &'x [URI],
144        txt_cache: Option<&impl ResolverCache<String, Txt>>,
145    ) -> Option<Vec<&'x URI>> {
146        let mut result = Vec::with_capacity(addresses.len());
147        for address in addresses {
148            if address.uri.ends_with(domain)
149                || match self
150                    .txt_lookup::<Dmarc>(
151                        format!(
152                            "{}._report._dmarc.{}.",
153                            domain,
154                            address
155                                .uri
156                                .rsplit_once('@')
157                                .map(|(_, d)| d)
158                                .unwrap_or_default()
159                        ),
160                        txt_cache,
161                    )
162                    .await
163                {
164                    Ok(_) => true,
165                    Err(Error::DnsError(_)) => return None,
166                    _ => false,
167                }
168            {
169                result.push(address);
170            }
171        }
172
173        result.into()
174    }
175
176    async fn dmarc_tree_walk(
177        &self,
178        domain: &str,
179        txt_cache: Option<&impl ResolverCache<String, Txt>>,
180    ) -> crate::Result<Option<Arc<Dmarc>>> {
181        let labels = domain.split('.').collect::<Vec<_>>();
182        let mut x = labels.len();
183        if x == 1 {
184            return Ok(None);
185        }
186        while x != 0 {
187            // Build query domain
188            let mut domain = String::with_capacity(domain.len() + 8);
189            domain.push_str("_dmarc");
190            for label in labels.iter().skip(labels.len() - x) {
191                domain.push('.');
192                domain.push_str(label);
193            }
194            domain.push('.');
195
196            // Query DMARC
197            match self.txt_lookup::<Dmarc>(domain, txt_cache).await {
198                Ok(dmarc) => {
199                    return Ok(Some(dmarc));
200                }
201                Err(Error::DnsRecordNotFound(_)) | Err(Error::InvalidRecordType) => (),
202                Err(err) => return Err(err),
203            }
204
205            // If x < 5, remove the left-most (highest-numbered) label from the subject domain.
206            // If x >= 5, remove the left-most (highest-numbered) labels from the subject
207            // domain until 4 labels remain.
208            if x < 5 {
209                x -= 1;
210            } else {
211                x = 4;
212            }
213        }
214
215        Ok(None)
216    }
217}
218
219impl<'x> DmarcParameters<'x, fn(&str) -> &str> {
220    pub fn new(
221        message: &'x AuthenticatedMessage<'x>,
222        dkim_output: &'x [DkimOutput<'x>],
223        rfc5321_mail_from_domain: &'x str,
224        spf_output: &'x SpfOutput,
225    ) -> Self {
226        Self {
227            message,
228            dkim_output,
229            rfc5321_mail_from_domain,
230            spf_output,
231            domain_suffix_fn: |d| d,
232        }
233    }
234}
235
236impl<'x, F> DmarcParameters<'x, F>
237where
238    F: for<'y> Fn(&'y str) -> &'y str,
239{
240    pub fn with_domain_suffix_fn<NewF>(self, f: NewF) -> DmarcParameters<'x, NewF>
241    where
242        NewF: for<'y> Fn(&'y str) -> &'y str,
243    {
244        DmarcParameters {
245            message: self.message,
246            dkim_output: self.dkim_output,
247            rfc5321_mail_from_domain: self.rfc5321_mail_from_domain,
248            spf_output: self.spf_output,
249            domain_suffix_fn: f,
250        }
251    }
252}
253
254impl<'x, F> From<DmarcParameters<'x, F>>
255    for Parameters<
256        'x,
257        DmarcParameters<'x, F>,
258        NoCache<String, Txt>,
259        NoCache<String, Arc<Vec<MX>>>,
260        NoCache<String, Arc<Vec<Ipv4Addr>>>,
261        NoCache<String, Arc<Vec<Ipv6Addr>>>,
262        NoCache<IpAddr, Arc<Vec<String>>>,
263    >
264where
265    F: for<'y> Fn(&'y str) -> &'y str,
266{
267    fn from(params: DmarcParameters<'x, F>) -> Self {
268        Parameters::new(params)
269    }
270}
271
272#[cfg(test)]
273#[allow(unused)]
274mod test {
275    use std::time::{Duration, Instant};
276
277    use mail_parser::MessageParser;
278
279    use crate::{
280        AuthenticatedMessage, DkimOutput, DkimResult, DmarcResult, Error, MessageAuthenticator,
281        SpfOutput, SpfResult,
282        common::{cache::test::DummyCaches, parse::TxtRecordParser},
283        dkim::Signature,
284        dmarc::{Dmarc, Policy, URI},
285    };
286
287    use super::DmarcParameters;
288
289    #[tokio::test]
290    async fn dmarc_verify() {
291        let resolver = MessageAuthenticator::new_system_conf().unwrap();
292        let caches = DummyCaches::new();
293
294        for (
295            dmarc_dns,
296            dmarc,
297            message,
298            rfc5321_mail_from_domain,
299            signature_domain,
300            dkim,
301            spf,
302            expect_dkim,
303            expect_spf,
304            policy,
305        ) in [
306            // Strict - Pass
307            (
308                "_dmarc.example.org.",
309                concat!(
310                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
311                    "rua=mailto:dmarc-feedback@example.org"
312                ),
313                "From: hello@example.org\r\n\r\n",
314                "example.org",
315                "example.org",
316                DkimResult::Pass,
317                SpfResult::Pass,
318                DmarcResult::Pass,
319                DmarcResult::Pass,
320                Policy::Reject,
321            ),
322            // Relaxed - Pass
323            (
324                "_dmarc.example.org.",
325                concat!(
326                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=r; adkim=r; fo=1;",
327                    "rua=mailto:dmarc-feedback@example.org"
328                ),
329                "From: hello@example.org\r\n\r\n",
330                "subdomain.example.org",
331                "subdomain.example.org",
332                DkimResult::Pass,
333                SpfResult::Pass,
334                DmarcResult::Pass,
335                DmarcResult::Pass,
336                Policy::Quarantine,
337            ),
338            // Strict - Fail
339            (
340                "_dmarc.example.org.",
341                concat!(
342                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
343                    "rua=mailto:dmarc-feedback@example.org"
344                ),
345                "From: hello@example.org\r\n\r\n",
346                "subdomain.example.org",
347                "subdomain.example.org",
348                DkimResult::Pass,
349                SpfResult::Pass,
350                DmarcResult::Fail(Error::NotAligned),
351                DmarcResult::Fail(Error::NotAligned),
352                Policy::Quarantine,
353            ),
354            // Strict - Pass with tree walk
355            (
356                "_dmarc.example.org.",
357                concat!(
358                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
359                    "rua=mailto:dmarc-feedback@example.org"
360                ),
361                "From: hello@a.b.c.example.org\r\n\r\n",
362                "a.b.c.example.org",
363                "a.b.c.example.org",
364                DkimResult::Pass,
365                SpfResult::Pass,
366                DmarcResult::Pass,
367                DmarcResult::Pass,
368                Policy::Reject,
369            ),
370            // Relaxed - Pass with tree walk
371            (
372                "_dmarc.c.example.org.",
373                concat!(
374                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=r; adkim=r; fo=1;",
375                    "rua=mailto:dmarc-feedback@example.org"
376                ),
377                "From: hello@a.b.c.example.org\r\n\r\n",
378                "example.org",
379                "example.org",
380                DkimResult::Pass,
381                SpfResult::Pass,
382                DmarcResult::Pass,
383                DmarcResult::Pass,
384                Policy::Quarantine,
385            ),
386            // Relaxed - Pass with tree walk and different subdomains
387            (
388                "_dmarc.c.example.org.",
389                concat!(
390                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=r; adkim=r; fo=1;",
391                    "rua=mailto:dmarc-feedback@example.org"
392                ),
393                "From: hello@a.b.c.example.org\r\n\r\n",
394                "z.example.org",
395                "z.example.org",
396                DkimResult::Pass,
397                SpfResult::Pass,
398                DmarcResult::Pass,
399                DmarcResult::Pass,
400                Policy::Quarantine,
401            ),
402            // Failed mechanisms
403            (
404                "_dmarc.example.org.",
405                concat!(
406                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
407                    "rua=mailto:dmarc-feedback@example.org"
408                ),
409                "From: hello@example.org\r\n\r\n",
410                "example.org",
411                "example.org",
412                DkimResult::Fail(Error::SignatureExpired),
413                SpfResult::Fail,
414                DmarcResult::None,
415                DmarcResult::None,
416                Policy::Reject,
417            ),
418        ] {
419            caches.txt_add(
420                dmarc_dns,
421                Dmarc::parse(dmarc.as_bytes()).unwrap(),
422                Instant::now() + Duration::new(3200, 0),
423            );
424
425            let auth_message = AuthenticatedMessage::parse(message.as_bytes()).unwrap();
426            assert_eq!(
427                auth_message,
428                AuthenticatedMessage::from_parsed(
429                    &MessageParser::new().parse(message).unwrap(),
430                    true
431                )
432            );
433            let signature = Signature {
434                d: signature_domain.into(),
435                ..Default::default()
436            };
437            let dkim = DkimOutput {
438                result: dkim,
439                signature: (&signature).into(),
440                report: None,
441                is_atps: false,
442            };
443            let spf = SpfOutput {
444                result: spf,
445                domain: rfc5321_mail_from_domain.to_string(),
446                report: None,
447                explanation: None,
448            };
449            let result = resolver
450                .verify_dmarc(
451                    caches.parameters(
452                        DmarcParameters::new(
453                            &auth_message,
454                            &[dkim],
455                            rfc5321_mail_from_domain,
456                            &spf,
457                        )
458                        .with_domain_suffix_fn(|d| psl::domain_str(d).unwrap_or(d)),
459                    ),
460                )
461                .await;
462            assert_eq!(result.dkim_result, expect_dkim);
463            assert_eq!(result.spf_result, expect_spf);
464            assert_eq!(result.policy, policy);
465        }
466    }
467
468    #[tokio::test]
469    async fn dmarc_verify_report_address() {
470        let resolver = MessageAuthenticator::new_system_conf().unwrap();
471        let caches = DummyCaches::new().with_txt(
472            "example.org._report._dmarc.external.org.",
473            Dmarc::parse(b"v=DMARC1").unwrap(),
474            Instant::now() + Duration::new(3200, 0),
475        );
476        let uris = vec![
477            URI::new("dmarc@example.org", 0),
478            URI::new("dmarc@external.org", 0),
479            URI::new("domain@other.org", 0),
480        ];
481
482        assert_eq!(
483            resolver
484                .verify_dmarc_report_address("example.org", &uris, Some(&caches.txt))
485                .await
486                .unwrap(),
487            vec![
488                &URI::new("dmarc@example.org", 0),
489                &URI::new("dmarc@external.org", 0),
490            ]
491        );
492    }
493}