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