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