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