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