Skip to main content

mail_auth/dmarc/
parse.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 crate::DnsError;
8use crate::{
9    Error, Version,
10    common::parse::{ItemParser, N, T, TagParser, TxtRecordParser, V, Y},
11};
12use mail_parser::decoders::quoted_printable::quoted_printable_decode_char;
13use std::slice::Iter;
14
15use super::{Alignment, Dmarc, Format, Policy, Psd, Report, URI};
16
17impl TxtRecordParser for Dmarc {
18    fn parse(bytes: &[u8]) -> crate::Result<Self> {
19        let mut record = bytes.iter();
20        if record.key().unwrap_or(0) != V
21            || !record.match_bytes(b"DMARC1")
22            || !record.seek_tag_end()
23        {
24            return Err(Error::Dns(DnsError::InvalidRecordType));
25        }
26
27        let mut dmarc = Dmarc {
28            adkim: Alignment::Relaxed,
29            aspf: Alignment::Relaxed,
30            fo: Report::All,
31            np: Policy::Unspecified,
32            p: Policy::Unspecified,
33            pct: 100,
34            rf: Format::Afrf as u8,
35            ri: 86400,
36            rua: vec![],
37            ruf: vec![],
38            sp: Policy::Unspecified,
39            v: Version::V1,
40            psd: Psd::Default,
41            t: false,
42        };
43
44        while let Some(key) = record.key() {
45            match key {
46                ADKIM => {
47                    dmarc.adkim = record.alignment()?;
48                }
49                ASPF => {
50                    dmarc.aspf = record.alignment()?;
51                }
52                FO => {
53                    dmarc.fo = record.report()?;
54                }
55                NP => {
56                    dmarc.np = record.policy()?;
57                }
58                P => {
59                    dmarc.p = record.policy()?;
60                }
61                PCT => {
62                    dmarc.pct = std::cmp::min(100, record.number().ok_or(Error::ParseError)?) as u8;
63                }
64                RF => {
65                    dmarc.rf = record.flags::<Format>() as u8;
66                }
67                RI => {
68                    dmarc.ri = record.number().ok_or(Error::ParseError)? as u32;
69                }
70                RUA => {
71                    dmarc.rua = record.uris()?;
72                }
73                RUF => {
74                    dmarc.ruf = record.uris()?;
75                }
76                SP => {
77                    dmarc.sp = record.policy()?;
78                }
79                PSD => {
80                    dmarc.psd = match record.value() {
81                        Y => Psd::Yes,
82                        N => Psd::No,
83                        _ => Psd::Default,
84                    };
85                }
86                T => {
87                    dmarc.t = record.value() == Y;
88                }
89                _ => {
90                    record.ignore();
91                }
92            }
93        }
94
95        if dmarc.sp == Policy::Unspecified {
96            dmarc.sp = dmarc.p;
97        }
98        if dmarc.np == Policy::Unspecified {
99            dmarc.np = dmarc.sp;
100        }
101
102        Ok(dmarc)
103    }
104}
105
106pub(crate) trait DMARCParser: Sized {
107    fn alignment(&mut self) -> crate::Result<Alignment>;
108    fn report(&mut self) -> crate::Result<Report>;
109    fn policy(&mut self) -> crate::Result<Policy>;
110    fn uris(&mut self) -> crate::Result<Vec<URI>>;
111}
112
113impl DMARCParser for Iter<'_, u8> {
114    fn alignment(&mut self) -> crate::Result<Alignment> {
115        let a = match self.next_skip_whitespaces().unwrap_or(0) {
116            b'r' | b'R' => Alignment::Relaxed,
117            b's' | b'S' => Alignment::Strict,
118            _ => return Err(Error::ParseError),
119        };
120        if self.seek_tag_end() {
121            Ok(a)
122        } else {
123            Err(Error::ParseError)
124        }
125    }
126
127    fn report(&mut self) -> crate::Result<Report> {
128        let mut r = Report::All;
129
130        loop {
131            r = match self.next_skip_whitespaces().unwrap_or(0) {
132                b'0' => Report::All,
133                b'1' => Report::Any,
134                b'd' | b'D' => {
135                    if r == Report::Spf {
136                        Report::DkimSpf
137                    } else {
138                        Report::Dkim
139                    }
140                }
141                b's' | b'S' => {
142                    if r == Report::Dkim {
143                        Report::DkimSpf
144                    } else {
145                        Report::Spf
146                    }
147                }
148                _ => return Err(Error::ParseError),
149            };
150            match self.next_skip_whitespaces().unwrap_or(0) {
151                b':' => (),
152                b';' | 0 => return Ok(r),
153                _ => return Err(Error::ParseError),
154            }
155        }
156    }
157
158    fn policy(&mut self) -> crate::Result<Policy> {
159        let p = match self.next_skip_whitespaces().unwrap_or(0) {
160            b'n' | b'N' if self.match_bytes(b"one") => Policy::None,
161            b'q' | b'Q' if self.match_bytes(b"uarantine") => Policy::Quarantine,
162            b'r' | b'R' if self.match_bytes(b"eject") => Policy::Reject,
163            _ => return Err(Error::ParseError),
164        };
165        if self.seek_tag_end() {
166            Ok(p)
167        } else {
168            Err(Error::ParseError)
169        }
170    }
171
172    #[allow(clippy::while_let_on_iterator)]
173    fn uris(&mut self) -> crate::Result<Vec<URI>> {
174        let mut uris = Vec::new();
175        let mut uri = Vec::with_capacity(16);
176        let mut found_uri = false;
177        let mut found_at = false;
178        let mut size: usize = 0;
179
180        'outer: while let Some(&ch) = self.next() {
181            match ch {
182                b'%' => {
183                    let mut hex1 = 0;
184
185                    while let Some(&ch) = self.next() {
186                        if ch.is_ascii_hexdigit() {
187                            if hex1 != 0 {
188                                if let Some(ch) = quoted_printable_decode_char(hex1, ch) {
189                                    match ch {
190                                        b'@' => {
191                                            found_at = true;
192                                            uri.push(ch);
193                                        }
194                                        _ => {
195                                            if !ch.is_ascii_whitespace() {
196                                                uri.push(ch);
197                                            }
198                                        }
199                                    }
200                                }
201                                break;
202                            } else {
203                                hex1 = ch;
204                            }
205                        } else if ch == b';' {
206                            break 'outer;
207                        } else if !ch.is_ascii_whitespace() {
208                            return Err(Error::ParseError);
209                        }
210                    }
211                }
212                b'!' => {
213                    let mut has_digits = false;
214                    let mut has_units = false;
215
216                    while let Some(&ch) = self.next() {
217                        match ch {
218                            b'0'..=b'9' if !has_units => {
219                                size =
220                                    (size.saturating_mul(10)).saturating_add((ch - b'0') as usize);
221                                has_digits = true;
222                            }
223                            b'k' | b'K' if !has_units && has_digits => {
224                                size = size.saturating_mul(1024);
225                                has_units = true;
226                            }
227                            b'm' | b'M' if !has_units && has_digits => {
228                                size = size.saturating_mul(1024 * 1024);
229                                has_units = true;
230                            }
231                            b'g' | b'G' if !has_units && has_digits => {
232                                size = size.saturating_mul(1024 * 1024 * 1024);
233                                has_units = true;
234                            }
235                            b't' | b'T' if !has_units && has_digits => {
236                                size = usize::try_from(
237                                    (size as u64).saturating_mul(1024 * 1024 * 1024 * 1024),
238                                )
239                                .unwrap_or(usize::MAX);
240                                has_units = true;
241                            }
242                            b';' => {
243                                break 'outer;
244                            }
245                            b',' => {
246                                if !uri.is_empty() {
247                                    if found_uri && found_at {
248                                        uris.push(URI {
249                                            uri: String::from_utf8_lossy(&uri).to_lowercase(),
250                                            max_size: size,
251                                        });
252                                    }
253                                    found_uri = false;
254                                    found_at = false;
255                                    uri.clear();
256                                }
257                                size = 0;
258                                break;
259                            }
260                            _ => {
261                                if !ch.is_ascii_whitespace() {
262                                    return Err(Error::ParseError);
263                                }
264                            }
265                        }
266                    }
267                }
268                b',' => {
269                    if !uri.is_empty() {
270                        if found_uri && found_at {
271                            uris.push(URI {
272                                uri: String::from_utf8_lossy(&uri).to_lowercase(),
273                                max_size: size,
274                            });
275                        }
276                        found_uri = false;
277                        found_at = false;
278                        uri.clear();
279                    }
280                    size = 0;
281                }
282                b':' if !found_uri => {
283                    found_uri = uri.eq_ignore_ascii_case(b"mailto");
284                    uri.clear();
285                }
286                b';' => {
287                    break;
288                }
289                b'@' => {
290                    found_at = true;
291                    uri.push(ch);
292                }
293                _ => {
294                    if !ch.is_ascii_whitespace() {
295                        uri.push(ch);
296                    }
297                }
298            }
299        }
300
301        if !uri.is_empty() && found_uri && found_at {
302            uris.push(URI {
303                uri: String::from_utf8_lossy(&uri).to_lowercase(),
304                max_size: size,
305            })
306        }
307
308        Ok(uris)
309    }
310}
311
312impl ItemParser for Format {
313    fn parse(bytes: &[u8]) -> Option<Self> {
314        if bytes.eq_ignore_ascii_case(b"afrf") {
315            Format::Afrf.into()
316        } else {
317            None
318        }
319    }
320}
321
322const ADKIM: u64 = (b'a' as u64)
323    | ((b'd' as u64) << 8)
324    | ((b'k' as u64) << 16)
325    | ((b'i' as u64) << 24)
326    | ((b'm' as u64) << 32);
327const ASPF: u64 =
328    (b'a' as u64) | ((b's' as u64) << 8) | ((b'p' as u64) << 16) | ((b'f' as u64) << 24);
329const FO: u64 = (b'f' as u64) | ((b'o' as u64) << 8);
330const NP: u64 = (b'n' as u64) | ((b'p' as u64) << 8);
331const P: u64 = b'p' as u64;
332const PCT: u64 = (b'p' as u64) | ((b'c' as u64) << 8) | ((b't' as u64) << 16);
333const RF: u64 = (b'r' as u64) | ((b'f' as u64) << 8);
334const RI: u64 = (b'r' as u64) | ((b'i' as u64) << 8);
335const RUA: u64 = (b'r' as u64) | ((b'u' as u64) << 8) | ((b'a' as u64) << 16);
336const RUF: u64 = (b'r' as u64) | ((b'u' as u64) << 8) | ((b'f' as u64) << 16);
337const SP: u64 = (b's' as u64) | ((b'p' as u64) << 8);
338const PSD: u64 = (b'p' as u64) | ((b's' as u64) << 8) | ((b'd' as u64) << 16);
339
340#[cfg(test)]
341mod test {
342    use crate::{
343        Version,
344        common::parse::TxtRecordParser,
345        dmarc::{Alignment, Dmarc, Format, Policy, Psd, Report, URI},
346    };
347
348    #[test]
349    fn parse_dmarc() {
350        for (record, expected_result) in [
351            (
352                "v=DMARC1; p=none; rua=mailto:dmarc-feedback@example.com",
353                Dmarc {
354                    adkim: Alignment::Relaxed,
355                    aspf: Alignment::Relaxed,
356                    fo: Report::All,
357                    np: Policy::None,
358                    p: Policy::None,
359                    pct: 100,
360                    rf: Format::Afrf as u8,
361                    ri: 86400,
362                    rua: vec![URI::new("dmarc-feedback@example.com", 0)],
363                    ruf: vec![],
364                    sp: Policy::None,
365                    psd: Psd::Default,
366                    t: false,
367                    v: Version::V1,
368                },
369            ),
370            (
371                concat!(
372                    "v=DMARC1; p=none; rua=mailto:dmarc-feedback@example.com;",
373                    "ruf=mailto:auth-reports@example.com"
374                ),
375                Dmarc {
376                    adkim: Alignment::Relaxed,
377                    aspf: Alignment::Relaxed,
378                    fo: Report::All,
379                    np: Policy::None,
380                    p: Policy::None,
381                    pct: 100,
382                    rf: Format::Afrf as u8,
383                    ri: 86400,
384                    rua: vec![URI::new("dmarc-feedback@example.com", 0)],
385                    ruf: vec![URI::new("auth-reports@example.com", 0)],
386                    sp: Policy::None,
387                    psd: Psd::Default,
388                    t: false,
389                    v: Version::V1,
390                },
391            ),
392            (
393                concat!(
394                    "v=DMARC1; p=quarantine; rua=mailto:dmarc-feedback@example.com,",
395                    "mailto:tld-test@thirdparty.example.net!10m; pct=25; fo=d:s"
396                ),
397                Dmarc {
398                    adkim: Alignment::Relaxed,
399                    aspf: Alignment::Relaxed,
400                    fo: Report::DkimSpf,
401                    np: Policy::Quarantine,
402                    p: Policy::Quarantine,
403                    pct: 25,
404                    rf: Format::Afrf as u8,
405                    ri: 86400,
406                    ruf: vec![],
407                    rua: vec![
408                        URI::new("dmarc-feedback@example.com", 0),
409                        URI::new("tld-test@thirdparty.example.net", 10 * 1024 * 1024),
410                    ],
411                    sp: Policy::Quarantine,
412                    psd: Psd::Default,
413                    t: false,
414                    v: Version::V1,
415                },
416            ),
417            (
418                concat!(
419                    "v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo = 1;",
420                    "rua=mailto:dmarc-feedback@example.com"
421                ),
422                Dmarc {
423                    adkim: Alignment::Strict,
424                    aspf: Alignment::Strict,
425                    fo: Report::Any,
426                    np: Policy::None,
427                    p: Policy::Reject,
428                    pct: 100,
429                    rf: Format::Afrf as u8,
430                    ri: 86400,
431                    rua: vec![URI::new("dmarc-feedback@example.com", 0)],
432                    ruf: vec![],
433                    sp: Policy::Quarantine,
434                    psd: Psd::Default,
435                    t: false,
436                    v: Version::V1,
437                },
438            ),
439            (
440                concat!(
441                    "v=DMARC1; p=reject; ri = 3600; aspf=r; adkim =r; ",
442                    "rua=mailto:dmarc-feedback@example.com!10 K , mailto:user%20@example.com ! 2G;",
443                    "ignore_me= true; fo=s; rf = AfrF; ",
444                ),
445                Dmarc {
446                    adkim: Alignment::Relaxed,
447                    aspf: Alignment::Relaxed,
448                    fo: Report::Spf,
449                    np: Policy::Reject,
450                    p: Policy::Reject,
451                    pct: 100,
452                    rf: Format::Afrf as u8,
453                    ri: 3600,
454                    rua: vec![
455                        URI::new("dmarc-feedback@example.com", 10 * 1024),
456                        URI::new("user@example.com", 2 * 1024 * 1024 * 1024),
457                    ],
458                    ruf: vec![],
459                    sp: Policy::Reject,
460                    psd: Psd::Default,
461                    t: false,
462                    v: Version::V1,
463                },
464            ),
465            (
466                concat!(
467                    "v=DMARC1; p=quarantine; rua=mailto:dmarc-feedback@example.com,",
468                    "mailto:tld-test@thirdparty.example.net; fo=s:d; t=y; psd=y;;",
469                ),
470                Dmarc {
471                    adkim: Alignment::Relaxed,
472                    aspf: Alignment::Relaxed,
473                    fo: Report::DkimSpf,
474                    np: Policy::Quarantine,
475                    p: Policy::Quarantine,
476                    pct: 100,
477                    rf: Format::Afrf as u8,
478                    ri: 86400,
479                    rua: vec![
480                        URI::new("dmarc-feedback@example.com", 0),
481                        URI::new("tld-test@thirdparty.example.net", 0),
482                    ],
483                    ruf: vec![],
484                    sp: Policy::Quarantine,
485                    psd: Psd::Yes,
486                    t: true,
487                    v: Version::V1,
488                },
489            ),
490        ] {
491            assert_eq!(
492                Dmarc::parse(record.as_bytes())
493                    .unwrap_or_else(|err| panic!("{record:?} : {err:?}")),
494                expected_result,
495                "{record}"
496            );
497        }
498    }
499}