Skip to main content

mail_auth/common/
auth_results.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::headers::{HeaderWriter, IntegerBuffer, Writer, base64_encode_slice};
8#[cfg(feature = "arc")]
9use crate::{ArcOutput, arc::ArcError};
10use crate::{
11    AuthenticationResults, Dkim2Result, DkimOutput, DkimResult, DmarcOutput, DmarcResult, Error,
12    IprevOutput, IprevResult, ReceivedSpf, SpfOutput, SpfResult, dkim::DkimError,
13    dkim2::Dkim2Output,
14};
15use crate::{DnsError, common::crypto::CryptoError, dmarc::Policy};
16use std::{
17    borrow::Cow,
18    fmt::{Display, Write},
19    net::{IpAddr, Ipv4Addr},
20};
21
22impl<'x> AuthenticationResults<'x> {
23    pub fn new(hostname: &'x str) -> Self {
24        AuthenticationResults {
25            hostname,
26            auth_results: String::with_capacity(256),
27        }
28    }
29
30    pub fn with_dkim_results(mut self, dkim: &[DkimOutput], header_from: &str) -> Self {
31        for dkim in dkim {
32            self.set_dkim_result(dkim, header_from);
33        }
34        self
35    }
36
37    pub fn with_dkim_result(mut self, dkim: &DkimOutput, header_from: &str) -> Self {
38        self.set_dkim_result(dkim, header_from);
39        self
40    }
41
42    pub fn set_dkim_result(&mut self, dkim: &DkimOutput, header_from: &str) {
43        if !dkim.is_atps {
44            self.auth_results.push_str(";\r\n\tdkim=");
45        } else {
46            self.auth_results.push_str(";\r\n\tdkim-atps=");
47        }
48        dkim.result.as_auth_result(&mut self.auth_results);
49        if let Some(signature) = &dkim.signature {
50            if !signature.i.is_empty() {
51                self.auth_results.push_str(" header.i=");
52                push_quoted_pvalue(&mut self.auth_results, &signature.i);
53            } else {
54                self.auth_results.push_str(" header.d=");
55                push_pvalue(&mut self.auth_results, &signature.d);
56            }
57            self.auth_results.push_str(" header.s=");
58            push_pvalue(&mut self.auth_results, &signature.s);
59            if let Some(prefix) = signature.b.get(..6) {
60                self.auth_results.push_str(" header.b=");
61                let mut encoded = [0u8; 8];
62                let len = base64_encode_slice(prefix, &mut encoded);
63                self.auth_results.push_str(
64                    std::str::from_utf8(encoded.get(..len).unwrap_or_default()).unwrap_or_default(),
65                );
66            }
67        }
68
69        if dkim.is_atps {
70            self.auth_results.push_str(" header.from=");
71            push_quoted_pvalue(&mut self.auth_results, header_from);
72        }
73    }
74
75    pub fn with_dkim2_result(mut self, dkim2: &Dkim2Output) -> Self {
76        self.set_dkim2_result(dkim2);
77        self
78    }
79
80    pub fn set_dkim2_result(&mut self, dkim2: &Dkim2Output) {
81        self.auth_results.push_str(";\r\n\tdkim2=");
82        dkim2.result().as_auth_result(&mut self.auth_results);
83
84        let link = if matches!(dkim2.result(), Dkim2Result::Pass) {
85            dkim2.chain().first()
86        } else {
87            dkim2
88                .chain()
89                .iter()
90                .find(|link| !matches!(link.result, Dkim2Result::Pass))
91                .or_else(|| dkim2.chain().first())
92        };
93        if let Some(link) = link {
94            self.auth_results.push_str(" header.d=");
95            push_pvalue(&mut self.auth_results, &link.signature.d);
96            self.auth_results.push_str(" header.i=");
97            push_integer(&mut self.auth_results, link.signature.i as u64);
98        }
99    }
100
101    pub fn with_spf_ehlo_result(
102        mut self,
103        spf: &SpfOutput,
104        ip_addr: IpAddr,
105        ehlo_domain: &str,
106    ) -> Self {
107        let ehlo_domain = sanitize_pvalue(ehlo_domain);
108        self.auth_results.push_str(";\r\n\tspf=");
109        spf.result.as_spf_result(
110            &mut self.auth_results,
111            self.hostname,
112            [POSTMASTER_AT, ehlo_domain.as_ref()],
113            ip_addr,
114        );
115        self.auth_results.push_str(" smtp.helo=");
116        self.auth_results.push_str(ehlo_domain.as_ref());
117        self
118    }
119
120    pub fn with_spf_mailfrom_result(
121        mut self,
122        spf: &SpfOutput,
123        ip_addr: IpAddr,
124        from: &str,
125        ehlo_domain: &str,
126    ) -> Self {
127        let ehlo_domain = sanitize_pvalue(ehlo_domain);
128        let sanitized_from = sanitize_pvalue(from);
129        let mail_from = if !from.is_empty() {
130            [sanitized_from.as_ref(), ""]
131        } else {
132            [POSTMASTER_AT, ehlo_domain.as_ref()]
133        };
134        self.auth_results.push_str(";\r\n\tspf=");
135        spf.result
136            .as_spf_result(&mut self.auth_results, self.hostname, mail_from, ip_addr);
137        self.auth_results.push_str(" smtp.mailfrom=");
138        if !from.is_empty() {
139            push_quoted_pvalue(&mut self.auth_results, from);
140        } else {
141            self.auth_results.push_str("<>");
142        }
143        self
144    }
145
146    #[cfg(feature = "arc")]
147    pub fn with_arc_result(mut self, arc: &ArcOutput, remote_ip: IpAddr) -> Self {
148        self.auth_results.push_str(";\r\n\tarc=");
149        arc.result.as_auth_result(&mut self.auth_results);
150        self.auth_results.push_str(" smtp.remote-ip=");
151        push_ip_as_pvalue(&mut self.auth_results, remote_ip);
152        self
153    }
154
155    pub fn with_dmarc_result(mut self, dmarc: &DmarcOutput) -> Self {
156        self.auth_results.push_str(";\r\n\tdmarc=");
157        if dmarc.spf_result == DmarcResult::Pass || dmarc.dkim_result == DmarcResult::Pass {
158            DmarcResult::Pass.as_auth_result(&mut self.auth_results);
159        } else if dmarc.spf_result != DmarcResult::None {
160            dmarc.spf_result.as_auth_result(&mut self.auth_results);
161        } else if dmarc.dkim_result != DmarcResult::None {
162            dmarc.dkim_result.as_auth_result(&mut self.auth_results);
163        } else {
164            DmarcResult::None.as_auth_result(&mut self.auth_results);
165        }
166        self.auth_results.push_str(" header.from=");
167        push_pvalue(&mut self.auth_results, &dmarc.domain);
168        self.auth_results.push_str(match dmarc.policy {
169            Policy::Quarantine => " policy.dmarc=quarantine",
170            Policy::Reject => " policy.dmarc=reject",
171            Policy::None | Policy::Unspecified => " policy.dmarc=none",
172        });
173        self
174    }
175
176    pub fn with_iprev_result(mut self, iprev: &IprevOutput, remote_ip: IpAddr) -> Self {
177        self.auth_results.push_str(";\r\n\tiprev=");
178        iprev.result.as_auth_result(&mut self.auth_results);
179        self.auth_results.push_str(" policy.iprev=");
180        push_ip_as_pvalue(&mut self.auth_results, remote_ip);
181        self
182    }
183}
184
185impl Display for AuthenticationResults<'_> {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        f.write_str(self.hostname)?;
188        f.write_str(&self.auth_results)
189    }
190}
191
192impl HeaderWriter for AuthenticationResults<'_> {
193    fn write_header(&self, writer: &mut impl Writer) {
194        writer.write(b"Authentication-Results: ");
195        writer.write(self.hostname.as_bytes());
196        if !self.auth_results.is_empty() {
197            writer.write(self.auth_results.as_bytes());
198        } else {
199            writer.write(b"; none");
200        }
201        writer.write(b"\r\n");
202    }
203}
204
205impl HeaderWriter for ReceivedSpf {
206    fn write_header(&self, writer: &mut impl Writer) {
207        writer.write(b"Received-SPF: ");
208        writer.write(self.received_spf.as_bytes());
209        writer.write(b"\r\n");
210    }
211}
212
213impl ReceivedSpf {
214    pub fn new(
215        spf: &SpfOutput,
216        ip_addr: IpAddr,
217        helo: &str,
218        mail_from: &str,
219        hostname: &str,
220    ) -> Self {
221        let mut received_spf = String::with_capacity(256);
222        let helo = sanitize_pvalue(helo);
223        let sanitized_from = sanitize_pvalue(mail_from);
224        let envelope_from = if !mail_from.is_empty() {
225            [mail_from, ""]
226        } else {
227            [POSTMASTER_AT, helo.as_ref()]
228        };
229        let pieces = if !mail_from.is_empty() {
230            [sanitized_from.as_ref(), ""]
231        } else {
232            [POSTMASTER_AT, helo.as_ref()]
233        };
234
235        spf.result
236            .as_spf_result(&mut received_spf, hostname, pieces, ip_addr);
237
238        received_spf.push_str("\r\n\treceiver=");
239        received_spf.push_str(hostname);
240        received_spf.push_str("; client-ip=");
241        push_ip(&mut received_spf, ip_addr);
242        received_spf.push_str("; envelope-from=\"");
243        for piece in envelope_from {
244            push_qcontent(&mut received_spf, piece);
245        }
246        received_spf.push_str("\"; helo=");
247        received_spf.push_str(helo.as_ref());
248        received_spf.push(';');
249
250        ReceivedSpf { received_spf }
251    }
252}
253
254const POSTMASTER_AT: &str = "postmaster@";
255const MAX_IP_TEXT_LEN: usize = 46;
256
257impl SpfResult {
258    fn as_spf_result(
259        &self,
260        header: &mut String,
261        hostname: &str,
262        mail_from: [&str; 2],
263        ip_addr: IpAddr,
264    ) {
265        let (result, reason, designation, close) = match self {
266            SpfResult::Pass => (
267                "pass (",
268                ": domain of ",
269                Some(" designates "),
270                " as permitted sender)",
271            ),
272            SpfResult::Fail => (
273                "fail (",
274                ": domain of ",
275                Some(" does not designate "),
276                " as permitted sender)",
277            ),
278            SpfResult::SoftFail => (
279                "softfail (",
280                ": domain of ",
281                Some(" reports soft fail for "),
282                ")",
283            ),
284            SpfResult::Neutral => (
285                "neutral (",
286                ": domain of ",
287                Some(" reports neutral for "),
288                ")",
289            ),
290            SpfResult::TempError => (
291                "temperror (",
292                ": temporary dns error validating ",
293                None,
294                ")",
295            ),
296            SpfResult::PermError => (
297                "permerror (",
298                ": unable to verify SPF record for ",
299                None,
300                ")",
301            ),
302            SpfResult::None => ("none (", ": no SPF records found for ", None, ")"),
303        };
304
305        let mail_from_len = mail_from[0].len() + mail_from[1].len();
306        header.reserve(
307            result.len()
308                + hostname.len()
309                + reason.len()
310                + mail_from_len
311                + close.len()
312                + designation.map_or(0, |text| text.len() + MAX_IP_TEXT_LEN),
313        );
314
315        header.push_str(result);
316        header.push_str(hostname);
317        header.push_str(reason);
318        for piece in mail_from {
319            header.push_str(piece);
320        }
321        if let Some(designation) = designation {
322            header.push_str(designation);
323            push_ip(header, ip_addr);
324        }
325        header.push_str(close);
326    }
327}
328
329pub trait AsAuthResult {
330    fn as_auth_result(&self, header: &mut String);
331}
332
333impl AsAuthResult for DmarcResult {
334    fn as_auth_result(&self, header: &mut String) {
335        match &self {
336            DmarcResult::Pass => header.push_str("pass"),
337            DmarcResult::Fail(err) => {
338                header.push_str("fail");
339                err.as_auth_result(header);
340            }
341            DmarcResult::PermError(err) => {
342                header.push_str("permerror");
343                err.as_auth_result(header);
344            }
345            DmarcResult::TempError(err) => {
346                header.push_str("temperror");
347                err.as_auth_result(header);
348            }
349            DmarcResult::None => header.push_str("none"),
350        }
351    }
352}
353
354impl AsAuthResult for IprevResult {
355    fn as_auth_result(&self, header: &mut String) {
356        match &self {
357            IprevResult::Pass => header.push_str("pass"),
358            IprevResult::Fail(err) => {
359                header.push_str("fail");
360                err.as_auth_result(header);
361            }
362            IprevResult::PermError(err) => {
363                header.push_str("permerror");
364                err.as_auth_result(header);
365            }
366            IprevResult::TempError(err) => {
367                header.push_str("temperror");
368                err.as_auth_result(header);
369            }
370            IprevResult::None => header.push_str("none"),
371        }
372    }
373}
374
375impl AsAuthResult for DkimResult {
376    fn as_auth_result(&self, header: &mut String) {
377        match &self {
378            DkimResult::Pass => header.push_str("pass"),
379            DkimResult::Neutral(err) => {
380                header.push_str("neutral");
381                err.as_auth_result(header);
382            }
383            DkimResult::Fail(err) => {
384                header.push_str("fail");
385                err.as_auth_result(header);
386            }
387            DkimResult::PermError(err) => {
388                header.push_str("permerror");
389                err.as_auth_result(header);
390            }
391            DkimResult::TempError(err) => {
392                header.push_str("temperror");
393                err.as_auth_result(header);
394            }
395            DkimResult::None => header.push_str("none"),
396        }
397    }
398}
399
400impl AsAuthResult for Dkim2Result {
401    fn as_auth_result(&self, header: &mut String) {
402        match &self {
403            Dkim2Result::Pass => header.push_str("pass"),
404            Dkim2Result::Fail(err) => {
405                header.push_str("fail");
406                err.as_auth_result(header);
407            }
408            Dkim2Result::PermError(err) => {
409                header.push_str("permerror");
410                err.as_auth_result(header);
411            }
412            Dkim2Result::TempError(err) => {
413                header.push_str("temperror");
414                err.as_auth_result(header);
415            }
416            Dkim2Result::None => header.push_str("none"),
417        }
418    }
419}
420
421impl AsAuthResult for Error {
422    fn as_auth_result(&self, header: &mut String) {
423        header.push_str(" (");
424        header.push_str(match self {
425            Error::ParseError => "dns record parse error",
426            Error::MissingParameters => "missing parameters",
427            Error::NoHeadersFound => "no headers found",
428            Error::Crypto(CryptoError::Library(_)) => "verification failed",
429            Error::Io(_) => "i/o error",
430            Error::Base64 => "base64 error",
431            Error::Dkim(DkimError::UnsupportedAlgorithm) => "unsupported algorithm",
432            Error::Dkim(DkimError::UnsupportedCanonicalization) => "unsupported canonicalization",
433            Error::Dkim(DkimError::UnsupportedKeyType) => "unsupported key type",
434            Error::Crypto(CryptoError::FailedVerification) => "verification failed",
435            Error::Crypto(CryptoError::IncompatibleAlgorithms) => {
436                "incompatible record/signature algorithms"
437            }
438            Error::Dns(DnsError::Resolver(_)) => "dns error",
439            Error::Dns(DnsError::RecordNotFound(_)) => "dns record not found",
440            Error::Dkim(DkimError::UnsupportedVersion) => "unsupported version",
441            Error::Dkim(DkimError::FailedBodyHashMatch) => "body hash did not verify",
442            #[cfg(feature = "arc")]
443            Error::Arc(ArcError::FailedBodyHashMatch) => "body hash did not verify",
444            Error::Dkim(DkimError::FailedAuidMatch) => "auid does not match",
445            Error::Dkim(DkimError::RevokedPublicKey) => "revoked public key",
446            Error::Dkim(DkimError::SignatureExpired) => "signature error",
447            #[cfg(feature = "arc")]
448            Error::Arc(ArcError::SignatureExpired) => "signature error",
449            Error::Dkim(DkimError::SignatureLength) => {
450                "signature length ignored due to security risk"
451            }
452            #[cfg(feature = "arc")]
453            Error::Arc(ArcError::SignatureLength) => {
454                "signature length ignored due to security risk"
455            }
456            #[cfg(feature = "arc")]
457            Error::Arc(ArcError::InvalidInstance(i)) => {
458                write!(header, "invalid ARC instance {i})").ok();
459                return;
460            }
461            #[cfg(feature = "arc")]
462            Error::Arc(ArcError::InvalidCV) => "invalid ARC cv",
463            #[cfg(feature = "arc")]
464            Error::Arc(ArcError::ChainTooLong) => "too many ARC headers",
465            #[cfg(feature = "arc")]
466            Error::Arc(ArcError::HasHeaderTag) => "ARC has header tag",
467            #[cfg(feature = "arc")]
468            Error::Arc(ArcError::BrokenChain) => "broken ARC chain",
469            Error::NotAligned => "policy not aligned",
470            Error::Dns(DnsError::InvalidRecordType) => "invalid dns record type",
471            Error::Dkim2(e) => {
472                write!(header, "{e})").ok();
473                return;
474            }
475        });
476        header.push(')');
477    }
478}
479
480/// Encodes the IP address to be used in a [`pvalue`] field.
481///
482/// IPv4 addresses can be used as-is, but IPv6 addresses need to be quoted
483/// since they contain `:` characters.
484///
485/// [`pvalue`]: https://datatracker.ietf.org/doc/html/rfc8601#section-2.2
486fn push_ip_as_pvalue(header: &mut String, ip: IpAddr) {
487    match ip {
488        IpAddr::V4(addr) => push_ipv4(header, addr),
489        IpAddr::V6(addr) => {
490            header.push('"');
491            write!(header, "{addr}").ok();
492            header.push('"');
493        }
494    }
495}
496
497fn push_ip(header: &mut String, ip: IpAddr) {
498    match ip {
499        IpAddr::V4(addr) => push_ipv4(header, addr),
500        IpAddr::V6(addr) => {
501            write!(header, "{addr}").ok();
502        }
503    }
504}
505
506fn push_ipv4(header: &mut String, addr: Ipv4Addr) {
507    const MAX_IPV4_TEXT_LEN: usize = 15;
508
509    let mut text = [0u8; MAX_IPV4_TEXT_LEN];
510    let mut len = 0;
511    let mut push = |byte: u8| {
512        if let Some(slot) = text.get_mut(len) {
513            *slot = byte;
514            len += 1;
515        }
516    };
517
518    for (pos, octet) in addr.octets().into_iter().enumerate() {
519        if pos > 0 {
520            push(b'.');
521        }
522        if octet >= 100 {
523            push(b'0' + octet / 100);
524        }
525        if octet >= 10 {
526            push(b'0' + (octet / 10) % 10);
527        }
528        push(b'0' + octet % 10);
529    }
530
531    header.push_str(std::str::from_utf8(text.get(..len).unwrap_or_default()).unwrap_or_default());
532}
533
534fn push_integer(header: &mut String, value: u64) {
535    let mut integer = IntegerBuffer::new();
536    header.push_str(integer.text(value));
537}
538
539#[inline]
540fn is_pvalue_safe(ch: char) -> bool {
541    !matches!(ch, '\0'..=' ' | '\u{7f}'..='\u{9f}' | '(' | ')' | ';' | '=' | '"' | '\\')
542}
543
544#[inline(always)]
545fn is_pvalue_safe_ascii(ch: u8) -> bool {
546    !matches!(ch, 0..=b' ' | 0x7f..=u8::MAX | b'(' | b')' | b';' | b'=' | b'"' | b'\\')
547}
548
549#[inline]
550fn is_pvalue_clean(value: &str) -> bool {
551    value.bytes().all(is_pvalue_safe_ascii) || value.chars().all(is_pvalue_safe)
552}
553
554#[inline]
555fn sanitize_pvalue(value: &str) -> Cow<'_, str> {
556    if is_pvalue_clean(value) {
557        Cow::Borrowed(value)
558    } else {
559        Cow::Owned(value.chars().filter(|&ch| is_pvalue_safe(ch)).collect())
560    }
561}
562
563#[inline]
564fn push_pvalue(header: &mut String, value: &str) {
565    if is_pvalue_clean(value) {
566        header.push_str(value);
567    } else {
568        header.extend(value.chars().filter(|&ch| is_pvalue_safe(ch)));
569    }
570}
571
572#[inline]
573fn push_quoted_pvalue(header: &mut String, value: &str) {
574    if !value.is_empty() && is_pvalue_clean(value) {
575        header.push_str(value);
576    } else {
577        header.push('"');
578        push_qcontent(header, value);
579        header.push('"');
580    }
581}
582
583#[inline]
584fn push_qcontent(header: &mut String, value: &str) {
585    let mut start = 0;
586    for (pos, ch) in value.char_indices() {
587        match ch {
588            '"' | '\\' => {
589                header.push_str(value.get(start..pos).unwrap_or_default());
590                header.push('\\');
591                header.push(ch);
592                start = pos + 1;
593            }
594            '\0'..='\u{1f}' | '\u{7f}'..='\u{9f}' => {
595                header.push_str(value.get(start..pos).unwrap_or_default());
596                start = pos + ch.len_utf8();
597            }
598            _ => {}
599        }
600    }
601    header.push_str(value.get(start..).unwrap_or_default());
602}
603
604#[cfg(test)]
605mod test {
606    #[cfg(feature = "arc")]
607    use crate::{ArcOutput, arc::ArcError};
608    use crate::{
609        AuthenticationResults, DkimOutput, DkimResult, DmarcOutput, DmarcResult, DnsError, Error,
610        IprevOutput, IprevResult, ReceivedSpf, SpfOutput, SpfResult, common::crypto::CryptoError,
611        dkim::Signature, dmarc::Policy,
612    };
613
614    #[test]
615    fn authentication_results() {
616        let mut auth_results = AuthenticationResults::new("mydomain.org");
617
618        for (expected_auth_results, dkim) in [
619            (
620                "dkim=pass header.d=example.org header.s=myselector",
621                DkimOutput {
622                    result: DkimResult::Pass,
623                    signature: (&Signature {
624                        d: "example.org".into(),
625                        s: "myselector".into(),
626                        ..Default::default()
627                    })
628                        .into(),
629                    report: None,
630                    is_atps: false,
631                },
632            ),
633            (
634                concat!(
635                    "dkim=fail (verification failed) header.d=example.org ",
636                    "header.s=myselector header.b=MTIzNDU2"
637                ),
638                DkimOutput {
639                    result: DkimResult::Fail(Error::Crypto(CryptoError::FailedVerification)),
640                    signature: (&Signature {
641                        d: "example.org".into(),
642                        s: "myselector".into(),
643                        b: b"123456".to_vec(),
644                        ..Default::default()
645                    })
646                        .into(),
647                    report: None,
648                    is_atps: false,
649                },
650            ),
651            (
652                concat!(
653                    "dkim-atps=temperror (dns error) header.d=atps.example.org ",
654                    "header.s=otherselctor header.b=YWJjZGVm header.from=jdoe@example.org"
655                ),
656                DkimOutput {
657                    result: DkimResult::TempError(Error::Dns(DnsError::Resolver("".to_string()))),
658                    signature: (&Signature {
659                        d: "atps.example.org".into(),
660                        s: "otherselctor".into(),
661                        b: b"abcdef".to_vec(),
662                        ..Default::default()
663                    })
664                        .into(),
665                    report: None,
666                    is_atps: true,
667                },
668            ),
669        ] {
670            auth_results = auth_results.with_dkim_results(&[dkim], "jdoe@example.org");
671            assert_eq!(
672                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
673                expected_auth_results
674            );
675        }
676
677        for (
678            expected_auth_results,
679            expected_received_spf,
680            result,
681            ip_addr,
682            receiver,
683            helo,
684            mail_from,
685        ) in [
686            (
687                concat!(
688                    "spf=pass (localhost: domain of jdoe@example.org designates 192.168.1.1 ",
689                    "as permitted sender) smtp.mailfrom=jdoe@example.org"
690                ),
691                concat!(
692                    "pass (localhost: domain of jdoe@example.org designates 192.168.1.1 as ",
693                    "permitted sender)\r\n\treceiver=localhost; client-ip=192.168.1.1; ",
694                    "envelope-from=\"jdoe@example.org\"; helo=example.org;"
695                ),
696                SpfResult::Pass,
697                "192.168.1.1".parse().unwrap(),
698                "localhost",
699                "example.org",
700                "jdoe@example.org",
701            ),
702            (
703                concat!(
704                    "spf=fail (mx.domain.org: domain of sender@otherdomain.org does not ",
705                    "designate a:b:c::f as permitted sender) smtp.mailfrom=sender@otherdomain.org"
706                ),
707                concat!(
708                    "fail (mx.domain.org: domain of sender@otherdomain.org does not designate ",
709                    "a:b:c::f as permitted sender)\r\n\treceiver=mx.domain.org; ",
710                    "client-ip=a:b:c::f; envelope-from=\"sender@otherdomain.org\"; ",
711                    "helo=otherdomain.org;"
712                ),
713                SpfResult::Fail,
714                "a:b:c::f".parse().unwrap(),
715                "mx.domain.org",
716                "otherdomain.org",
717                "sender@otherdomain.org",
718            ),
719            (
720                concat!(
721                    "spf=neutral (mx.domain.org: domain of postmaster@example.org reports neutral ",
722                    "for a:b:c::f) smtp.mailfrom=<>"
723                ),
724                concat!(
725                    "neutral (mx.domain.org: domain of postmaster@example.org reports neutral for ",
726                    "a:b:c::f)\r\n\treceiver=mx.domain.org; client-ip=a:b:c::f; ",
727                    "envelope-from=\"postmaster@example.org\"; helo=example.org;"
728                ),
729                SpfResult::Neutral,
730                "a:b:c::f".parse().unwrap(),
731                "mx.domain.org",
732                "example.org",
733                "",
734            ),
735        ] {
736            auth_results.hostname = receiver;
737            auth_results = auth_results.with_spf_mailfrom_result(
738                &SpfOutput {
739                    result,
740                    domain: "".to_string(),
741                    report: None,
742                    explanation: None,
743                },
744                ip_addr,
745                mail_from,
746                helo,
747            );
748            let received_spf = ReceivedSpf::new(
749                &SpfOutput {
750                    result,
751                    domain: "".to_string(),
752                    report: None,
753                    explanation: None,
754                },
755                ip_addr,
756                helo,
757                mail_from,
758                receiver,
759            );
760            assert_eq!(
761                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
762                expected_auth_results
763            );
764            assert_eq!(received_spf.received_spf, expected_received_spf);
765        }
766
767        for (expected_auth_results, dmarc) in [
768            (
769                "dmarc=pass header.from=example.org policy.dmarc=none",
770                DmarcOutput {
771                    spf_result: DmarcResult::Pass,
772                    dkim_result: DmarcResult::None,
773                    domain: "example.org".to_string(),
774                    policy: Policy::None,
775                    record: None,
776                },
777            ),
778            (
779                "dmarc=fail (policy not aligned) header.from=example.com policy.dmarc=quarantine",
780                DmarcOutput {
781                    dkim_result: DmarcResult::Fail(Error::NotAligned),
782                    spf_result: DmarcResult::None,
783                    domain: "example.com".to_string(),
784                    policy: Policy::Quarantine,
785                    record: None,
786                },
787            ),
788        ] {
789            auth_results = auth_results.with_dmarc_result(&dmarc);
790            assert_eq!(
791                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
792                expected_auth_results
793            );
794        }
795
796        #[cfg(feature = "arc")]
797        for (expected_auth_results, arc, remote_ip) in [
798            (
799                "arc=pass smtp.remote-ip=192.127.9.2",
800                DkimResult::Pass,
801                "192.127.9.2".parse().unwrap(),
802            ),
803            (
804                "arc=neutral (body hash did not verify) smtp.remote-ip=\"1:2:3::a\"",
805                DkimResult::Neutral(Error::Arc(ArcError::FailedBodyHashMatch)),
806                "1:2:3::a".parse().unwrap(),
807            ),
808        ] {
809            auth_results = auth_results.with_arc_result(
810                &ArcOutput {
811                    result: arc,
812                    set: vec![],
813                },
814                remote_ip,
815            );
816            assert_eq!(
817                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
818                expected_auth_results
819            );
820        }
821
822        for (expected_auth_results, iprev, remote_ip) in [
823            (
824                "iprev=pass policy.iprev=192.127.9.2",
825                IprevOutput {
826                    result: IprevResult::Pass,
827                    ptr: None,
828                },
829                "192.127.9.2".parse().unwrap(),
830            ),
831            (
832                "iprev=fail (policy not aligned) policy.iprev=\"1:2:3::a\"",
833                IprevOutput {
834                    result: IprevResult::Fail(Error::NotAligned),
835                    ptr: None,
836                },
837                "1:2:3::a".parse().unwrap(),
838            ),
839        ] {
840            auth_results = auth_results.with_iprev_result(&iprev, remote_ip);
841            assert_eq!(
842                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
843                expected_auth_results
844            );
845        }
846    }
847
848    #[test]
849    fn dkim2_authentication_results() {
850        use crate::{
851            Dkim2Result,
852            dkim2::{ChainLink, Dkim2Error, Dkim2Output, Signature as Dkim2Signature},
853        };
854
855        let originator = Dkim2Signature {
856            i: 1,
857            d: "example.org".into(),
858            ..Default::default()
859        };
860        let relay = Dkim2Signature {
861            i: 2,
862            d: "relay.example.com".into(),
863            ..Default::default()
864        };
865
866        let pass = Dkim2Output {
867            result: Dkim2Result::Pass,
868            chain: vec![
869                ChainLink {
870                    signature: &originator,
871                    instance: None,
872                    result: Dkim2Result::Pass,
873                    custody_ok: true,
874                },
875                ChainLink {
876                    signature: &relay,
877                    instance: None,
878                    result: Dkim2Result::Pass,
879                    custody_ok: true,
880                },
881            ],
882        };
883
884        let fail = Dkim2Output {
885            result: Dkim2Result::Fail(Error::Dkim2(Dkim2Error::BodyHashMismatch(2))),
886            chain: vec![
887                ChainLink {
888                    signature: &originator,
889                    instance: None,
890                    result: Dkim2Result::Pass,
891                    custody_ok: true,
892                },
893                ChainLink {
894                    signature: &relay,
895                    instance: None,
896                    result: Dkim2Result::Fail(Error::Dkim2(Dkim2Error::BodyHashMismatch(2))),
897                    custody_ok: true,
898                },
899            ],
900        };
901
902        let permerror: Dkim2Output =
903            Dkim2Result::PermError(Error::Dkim2(Dkim2Error::SignatureMissing(1))).into();
904
905        let none: Dkim2Output = Dkim2Result::None.into();
906
907        for (expected, output) in [
908            ("dkim2=pass header.d=example.org header.i=1", &pass),
909            (
910                concat!(
911                    "dkim2=fail (Message-Instance m=2 body hash mismatch) ",
912                    "header.d=relay.example.com header.i=2"
913                ),
914                &fail,
915            ),
916            ("dkim2=permerror (DKIM2-Signature i=1 missing)", &permerror),
917            ("dkim2=none", &none),
918        ] {
919            let auth_results = AuthenticationResults::new("mydomain.org").with_dkim2_result(output);
920            assert_eq!(
921                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
922                expected
923            );
924        }
925    }
926
927    #[test]
928    fn dkim_result_header_injection() {
929        let signature = Signature {
930            i: "u@evil.test\r\nReply-To: attacker@evil.test\r\nX-Injected: yes".into(),
931            d: "evil.test\r\nX-Injected-D: yes".into(),
932            s: "sel\r\nX-Injected-S: yes".into(),
933            b: b"123456".to_vec(),
934            ..Default::default()
935        };
936        let output = DkimOutput {
937            result: DkimResult::Fail(Error::Crypto(CryptoError::FailedVerification)),
938            signature: Some(&signature),
939            report: None,
940            is_atps: false,
941        };
942        let auth_results = AuthenticationResults::new("mx.example.org")
943            .with_dkim_result(&output, "from@example.org");
944
945        assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
946        let value = auth_results.auth_results.split_once("header.i=").unwrap().1;
947        assert!(!value.contains('\r') && !value.contains('\n'));
948        assert!(value.starts_with("\"u@evil.test"));
949        assert!(value.contains("Reply-To: attacker@evil.test"));
950    }
951
952    #[test]
953    fn dkim_result_header_i_quoted_local_part() {
954        let signature = Signature {
955            i: "a;b=c (note)\"x@example.org".into(),
956            d: "example.org".into(),
957            s: "sel".into(),
958            ..Default::default()
959        };
960        let output = DkimOutput {
961            result: DkimResult::Pass,
962            signature: Some(&signature),
963            report: None,
964            is_atps: false,
965        };
966        let auth_results = AuthenticationResults::new("mx.example.org")
967            .with_dkim_result(&output, "from@example.org");
968        let value = auth_results.auth_results.split_once("header.i=").unwrap().1;
969
970        assert!(value.starts_with("\"a;b=c (note)\\\"x@example.org\""));
971        assert_eq!(value.matches('"').count(), 3);
972    }
973
974    #[test]
975    fn dkim_result_header_d_injection() {
976        let signature = Signature {
977            d: "evil.test\r\nX-Injected: yes".into(),
978            s: "sel\"; smtp.bogus=1".into(),
979            ..Default::default()
980        };
981        let output = DkimOutput {
982            result: DkimResult::Fail(Error::Crypto(CryptoError::FailedVerification)),
983            signature: Some(&signature),
984            report: None,
985            is_atps: false,
986        };
987        let auth_results = AuthenticationResults::new("mx.example.org")
988            .with_dkim_result(&output, "from@example.org");
989
990        assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
991        let value = auth_results.auth_results.split_once("header.d=").unwrap().1;
992        assert!(!value.contains('\r') && !value.contains('\n'));
993        assert!(!value.contains('"') && !value.contains(';'));
994    }
995
996    #[test]
997    fn spf_result_header_injection() {
998        let spf = SpfOutput {
999            result: SpfResult::Pass,
1000            domain: String::new(),
1001            report: None,
1002            explanation: None,
1003        };
1004        let auth_results = AuthenticationResults::new("mx.example.org").with_spf_mailfrom_result(
1005            &spf,
1006            "192.168.1.1".parse().unwrap(),
1007            "a@evil.test\r\nX-Injected: yes",
1008            "helo.test\r\nX-Injected-Helo: yes",
1009        );
1010        assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
1011
1012        let auth_results = AuthenticationResults::new("mx.example.org").with_spf_ehlo_result(
1013            &spf,
1014            "192.168.1.1".parse().unwrap(),
1015            "helo.test\r\nX-Injected: yes",
1016        );
1017        assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
1018    }
1019
1020    #[test]
1021    fn dmarc_result_header_injection() {
1022        let auth_results =
1023            AuthenticationResults::new("mx.example.org").with_dmarc_result(&DmarcOutput {
1024                spf_result: DmarcResult::Pass,
1025                dkim_result: DmarcResult::None,
1026                domain: "evil.test\r\nX-Injected: yes".to_string(),
1027                policy: Policy::None,
1028                record: None,
1029            });
1030        assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
1031        let value = auth_results
1032            .auth_results
1033            .split_once("header.from=")
1034            .unwrap()
1035            .1;
1036        assert!(!value.contains('\r') && !value.contains('\n'));
1037    }
1038
1039    #[test]
1040    fn received_spf_header_injection() {
1041        let spf = SpfOutput {
1042            result: SpfResult::Pass,
1043            domain: String::new(),
1044            report: None,
1045            explanation: None,
1046        };
1047        let received_spf = ReceivedSpf::new(
1048            &spf,
1049            "192.168.1.1".parse().unwrap(),
1050            "helo.test\r\nX-Injected-Helo: yes",
1051            "a@evil.test\r\nX-Injected: yes\r\nReply-To: attacker@evil.test",
1052            "mx.example.org",
1053        );
1054        assert_eq!(received_spf.received_spf.matches("\r\n").count(), 1);
1055        assert!(
1056            !received_spf.received_spf.contains('"')
1057                || received_spf.received_spf.matches('"').count() == 2
1058        );
1059    }
1060}