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