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, 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};
16use mail_builder::encoders::base64::base64_encode;
17use std::{
18    borrow::Cow,
19    fmt::{Display, Write},
20    net::IpAddr,
21};
22
23impl<'x> AuthenticationResults<'x> {
24    pub fn new(hostname: &'x str) -> Self {
25        AuthenticationResults {
26            hostname,
27            auth_results: String::with_capacity(64),
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 signature.b.len() >= 6 {
61                self.auth_results.push_str(" header.b=");
62                self.auth_results.push_str(
63                    &String::from_utf8(base64_encode(&signature.b[..6]).unwrap_or_default())
64                        .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            write!(self.auth_results, " header.i={}", link.signature.i).ok();
97        }
98    }
99
100    pub fn with_spf_ehlo_result(
101        mut self,
102        spf: &SpfOutput,
103        ip_addr: IpAddr,
104        ehlo_domain: &str,
105    ) -> Self {
106        let ehlo_domain = sanitize_pvalue(ehlo_domain);
107        self.auth_results.push_str(";\r\n\tspf=");
108        spf.result.as_spf_result(
109            &mut self.auth_results,
110            self.hostname,
111            &format!("postmaster@{ehlo_domain}"),
112            ip_addr,
113        );
114        write!(self.auth_results, " smtp.helo={ehlo_domain}").ok();
115        self
116    }
117
118    pub fn with_spf_mailfrom_result(
119        mut self,
120        spf: &SpfOutput,
121        ip_addr: IpAddr,
122        from: &str,
123        ehlo_domain: &str,
124    ) -> Self {
125        let ehlo_domain = sanitize_pvalue(ehlo_domain);
126        let mail_from = if !from.is_empty() {
127            sanitize_pvalue(from)
128        } else {
129            Cow::Owned(format!("postmaster@{ehlo_domain}"))
130        };
131        self.auth_results.push_str(";\r\n\tspf=");
132        spf.result.as_spf_result(
133            &mut self.auth_results,
134            self.hostname,
135            mail_from.as_ref(),
136            ip_addr,
137        );
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        let _ = write!(self.auth_results, " smtp.remote-ip=");
152        let _ = format_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        write!(
168            self.auth_results,
169            " header.from={} policy.dmarc={}",
170            sanitize_pvalue(&dmarc.domain),
171            dmarc.policy
172        )
173        .ok();
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        let _ = write!(self.auth_results, " policy.iprev=");
181        let _ = format_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(64);
223        let helo = sanitize_pvalue(helo);
224        let envelope_from = if !mail_from.is_empty() {
225            Cow::Borrowed(mail_from)
226        } else {
227            Cow::Owned(format!("postmaster@{helo}"))
228        };
229        let mail_from = sanitize_pvalue(&envelope_from);
230
231        spf.result
232            .as_spf_result(&mut received_spf, hostname, mail_from.as_ref(), ip_addr);
233
234        write!(
235            received_spf,
236            "\r\n\treceiver={hostname}; client-ip={ip_addr}; envelope-from=\""
237        )
238        .ok();
239        push_qcontent(&mut received_spf, &envelope_from);
240        write!(received_spf, "\"; helo={helo};").ok();
241
242        ReceivedSpf { received_spf }
243    }
244}
245
246impl SpfResult {
247    fn as_spf_result(&self, header: &mut String, hostname: &str, mail_from: &str, ip_addr: IpAddr) {
248        match &self {
249            SpfResult::Pass => write!(
250                header,
251                "pass ({hostname}: domain of {mail_from} designates {ip_addr} as permitted sender)",
252            ),
253            SpfResult::Fail => write!(
254                header,
255                "fail ({hostname}: domain of {mail_from} does not designate {ip_addr} as permitted sender)",
256            ),
257            SpfResult::SoftFail => write!(
258                header,
259                "softfail ({hostname}: domain of {mail_from} reports soft fail for {ip_addr})",
260            ),
261            SpfResult::Neutral => write!(
262                header,
263                "neutral ({hostname}: domain of {mail_from} reports neutral for {ip_addr})",
264            ),
265            SpfResult::TempError => write!(
266                header,
267                "temperror ({hostname}: temporary dns error validating {mail_from})",
268            ),
269            SpfResult::PermError => write!(
270                header,
271                "permerror ({hostname}: unable to verify SPF record for {mail_from})",
272            ),
273            SpfResult::None => write!(
274                header,
275                "none ({hostname}: no SPF records found for {mail_from})",
276            ),
277        }
278        .ok();
279    }
280}
281
282pub trait AsAuthResult {
283    fn as_auth_result(&self, header: &mut String);
284}
285
286impl AsAuthResult for DmarcResult {
287    fn as_auth_result(&self, header: &mut String) {
288        match &self {
289            DmarcResult::Pass => header.push_str("pass"),
290            DmarcResult::Fail(err) => {
291                header.push_str("fail");
292                err.as_auth_result(header);
293            }
294            DmarcResult::PermError(err) => {
295                header.push_str("permerror");
296                err.as_auth_result(header);
297            }
298            DmarcResult::TempError(err) => {
299                header.push_str("temperror");
300                err.as_auth_result(header);
301            }
302            DmarcResult::None => header.push_str("none"),
303        }
304    }
305}
306
307impl AsAuthResult for IprevResult {
308    fn as_auth_result(&self, header: &mut String) {
309        match &self {
310            IprevResult::Pass => header.push_str("pass"),
311            IprevResult::Fail(err) => {
312                header.push_str("fail");
313                err.as_auth_result(header);
314            }
315            IprevResult::PermError(err) => {
316                header.push_str("permerror");
317                err.as_auth_result(header);
318            }
319            IprevResult::TempError(err) => {
320                header.push_str("temperror");
321                err.as_auth_result(header);
322            }
323            IprevResult::None => header.push_str("none"),
324        }
325    }
326}
327
328impl AsAuthResult for DkimResult {
329    fn as_auth_result(&self, header: &mut String) {
330        match &self {
331            DkimResult::Pass => header.push_str("pass"),
332            DkimResult::Neutral(err) => {
333                header.push_str("neutral");
334                err.as_auth_result(header);
335            }
336            DkimResult::Fail(err) => {
337                header.push_str("fail");
338                err.as_auth_result(header);
339            }
340            DkimResult::PermError(err) => {
341                header.push_str("permerror");
342                err.as_auth_result(header);
343            }
344            DkimResult::TempError(err) => {
345                header.push_str("temperror");
346                err.as_auth_result(header);
347            }
348            DkimResult::None => header.push_str("none"),
349        }
350    }
351}
352
353impl AsAuthResult for Dkim2Result {
354    fn as_auth_result(&self, header: &mut String) {
355        match &self {
356            Dkim2Result::Pass => header.push_str("pass"),
357            Dkim2Result::Fail(err) => {
358                header.push_str("fail");
359                err.as_auth_result(header);
360            }
361            Dkim2Result::PermError(err) => {
362                header.push_str("permerror");
363                err.as_auth_result(header);
364            }
365            Dkim2Result::TempError(err) => {
366                header.push_str("temperror");
367                err.as_auth_result(header);
368            }
369            Dkim2Result::None => header.push_str("none"),
370        }
371    }
372}
373
374impl AsAuthResult for Error {
375    fn as_auth_result(&self, header: &mut String) {
376        header.push_str(" (");
377        header.push_str(match self {
378            Error::ParseError => "dns record parse error",
379            Error::MissingParameters => "missing parameters",
380            Error::NoHeadersFound => "no headers found",
381            Error::Crypto(CryptoError::Library(_)) => "verification failed",
382            Error::Io(_) => "i/o error",
383            Error::Base64 => "base64 error",
384            Error::Dkim(DkimError::UnsupportedAlgorithm) => "unsupported algorithm",
385            Error::Dkim(DkimError::UnsupportedCanonicalization) => "unsupported canonicalization",
386            Error::Dkim(DkimError::UnsupportedKeyType) => "unsupported key type",
387            Error::Crypto(CryptoError::FailedVerification) => "verification failed",
388            Error::Crypto(CryptoError::IncompatibleAlgorithms) => {
389                "incompatible record/signature algorithms"
390            }
391            Error::Dns(DnsError::Resolver(_)) => "dns error",
392            Error::Dns(DnsError::RecordNotFound(_)) => "dns record not found",
393            Error::Dkim(DkimError::UnsupportedVersion) => "unsupported version",
394            Error::Dkim(DkimError::FailedBodyHashMatch) => "body hash did not verify",
395            #[cfg(feature = "arc")]
396            Error::Arc(ArcError::FailedBodyHashMatch) => "body hash did not verify",
397            Error::Dkim(DkimError::FailedAuidMatch) => "auid does not match",
398            Error::Dkim(DkimError::RevokedPublicKey) => "revoked public key",
399            Error::Dkim(DkimError::SignatureExpired) => "signature error",
400            #[cfg(feature = "arc")]
401            Error::Arc(ArcError::SignatureExpired) => "signature error",
402            Error::Dkim(DkimError::SignatureLength) => {
403                "signature length ignored due to security risk"
404            }
405            #[cfg(feature = "arc")]
406            Error::Arc(ArcError::SignatureLength) => {
407                "signature length ignored due to security risk"
408            }
409            #[cfg(feature = "arc")]
410            Error::Arc(ArcError::InvalidInstance(i)) => {
411                write!(header, "invalid ARC instance {i})").ok();
412                return;
413            }
414            #[cfg(feature = "arc")]
415            Error::Arc(ArcError::InvalidCV) => "invalid ARC cv",
416            #[cfg(feature = "arc")]
417            Error::Arc(ArcError::ChainTooLong) => "too many ARC headers",
418            #[cfg(feature = "arc")]
419            Error::Arc(ArcError::HasHeaderTag) => "ARC has header tag",
420            #[cfg(feature = "arc")]
421            Error::Arc(ArcError::BrokenChain) => "broken ARC chain",
422            Error::NotAligned => "policy not aligned",
423            Error::Dns(DnsError::InvalidRecordType) => "invalid dns record type",
424            Error::Dkim2(e) => {
425                write!(header, "{e})").ok();
426                return;
427            }
428        });
429        header.push(')');
430    }
431}
432
433/// Encodes the IP address to be used in a [`pvalue`] field.
434///
435/// IPv4 addresses can be used as-is, but IPv6 addresses need to be quoted
436/// since they contain `:` characters.
437///
438/// [`pvalue`]: https://datatracker.ietf.org/doc/html/rfc8601#section-2.2
439fn format_ip_as_pvalue(w: &mut impl Write, ip: IpAddr) -> std::fmt::Result {
440    match ip {
441        IpAddr::V4(addr) => write!(w, "{addr}"),
442        IpAddr::V6(addr) => write!(w, "\"{addr}\""),
443    }
444}
445
446#[inline]
447fn is_pvalue_safe(ch: char) -> bool {
448    !matches!(ch, '\0'..=' ' | '\u{7f}'..='\u{9f}' | '(' | ')' | ';' | '=' | '"' | '\\')
449}
450
451#[inline]
452fn sanitize_pvalue(value: &str) -> Cow<'_, str> {
453    if value.chars().all(is_pvalue_safe) {
454        Cow::Borrowed(value)
455    } else {
456        Cow::Owned(value.chars().filter(|&ch| is_pvalue_safe(ch)).collect())
457    }
458}
459
460#[inline]
461fn push_pvalue(header: &mut String, value: &str) {
462    header.extend(value.chars().filter(|&ch| is_pvalue_safe(ch)));
463}
464
465#[inline]
466fn push_quoted_pvalue(header: &mut String, value: &str) {
467    if !value.is_empty() && value.chars().all(is_pvalue_safe) {
468        header.push_str(value);
469    } else {
470        header.push('"');
471        push_qcontent(header, value);
472        header.push('"');
473    }
474}
475
476#[inline]
477fn push_qcontent(header: &mut String, value: &str) {
478    for ch in value.chars() {
479        match ch {
480            '"' | '\\' => {
481                header.push('\\');
482                header.push(ch);
483            }
484            '\0'..='\u{1f}' | '\u{7f}'..='\u{9f}' => {}
485            ch => header.push(ch),
486        }
487    }
488}
489
490#[cfg(test)]
491mod test {
492    #[cfg(feature = "arc")]
493    use crate::{ArcOutput, arc::ArcError};
494    use crate::{
495        AuthenticationResults, DkimOutput, DkimResult, DmarcOutput, DmarcResult, DnsError, Error,
496        IprevOutput, IprevResult, ReceivedSpf, SpfOutput, SpfResult, common::crypto::CryptoError,
497        dkim::Signature, dmarc::Policy,
498    };
499
500    #[test]
501    fn authentication_results() {
502        let mut auth_results = AuthenticationResults::new("mydomain.org");
503
504        for (expected_auth_results, dkim) in [
505            (
506                "dkim=pass header.d=example.org header.s=myselector",
507                DkimOutput {
508                    result: DkimResult::Pass,
509                    signature: (&Signature {
510                        d: "example.org".into(),
511                        s: "myselector".into(),
512                        ..Default::default()
513                    })
514                        .into(),
515                    report: None,
516                    is_atps: false,
517                },
518            ),
519            (
520                concat!(
521                    "dkim=fail (verification failed) header.d=example.org ",
522                    "header.s=myselector header.b=MTIzNDU2"
523                ),
524                DkimOutput {
525                    result: DkimResult::Fail(Error::Crypto(CryptoError::FailedVerification)),
526                    signature: (&Signature {
527                        d: "example.org".into(),
528                        s: "myselector".into(),
529                        b: b"123456".to_vec(),
530                        ..Default::default()
531                    })
532                        .into(),
533                    report: None,
534                    is_atps: false,
535                },
536            ),
537            (
538                concat!(
539                    "dkim-atps=temperror (dns error) header.d=atps.example.org ",
540                    "header.s=otherselctor header.b=YWJjZGVm header.from=jdoe@example.org"
541                ),
542                DkimOutput {
543                    result: DkimResult::TempError(Error::Dns(DnsError::Resolver("".to_string()))),
544                    signature: (&Signature {
545                        d: "atps.example.org".into(),
546                        s: "otherselctor".into(),
547                        b: b"abcdef".to_vec(),
548                        ..Default::default()
549                    })
550                        .into(),
551                    report: None,
552                    is_atps: true,
553                },
554            ),
555        ] {
556            auth_results = auth_results.with_dkim_results(&[dkim], "jdoe@example.org");
557            assert_eq!(
558                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
559                expected_auth_results
560            );
561        }
562
563        for (
564            expected_auth_results,
565            expected_received_spf,
566            result,
567            ip_addr,
568            receiver,
569            helo,
570            mail_from,
571        ) in [
572            (
573                concat!(
574                    "spf=pass (localhost: domain of jdoe@example.org designates 192.168.1.1 ",
575                    "as permitted sender) smtp.mailfrom=jdoe@example.org"
576                ),
577                concat!(
578                    "pass (localhost: domain of jdoe@example.org designates 192.168.1.1 as ",
579                    "permitted sender)\r\n\treceiver=localhost; client-ip=192.168.1.1; ",
580                    "envelope-from=\"jdoe@example.org\"; helo=example.org;"
581                ),
582                SpfResult::Pass,
583                "192.168.1.1".parse().unwrap(),
584                "localhost",
585                "example.org",
586                "jdoe@example.org",
587            ),
588            (
589                concat!(
590                    "spf=fail (mx.domain.org: domain of sender@otherdomain.org does not ",
591                    "designate a:b:c::f as permitted sender) smtp.mailfrom=sender@otherdomain.org"
592                ),
593                concat!(
594                    "fail (mx.domain.org: domain of sender@otherdomain.org does not designate ",
595                    "a:b:c::f as permitted sender)\r\n\treceiver=mx.domain.org; ",
596                    "client-ip=a:b:c::f; envelope-from=\"sender@otherdomain.org\"; ",
597                    "helo=otherdomain.org;"
598                ),
599                SpfResult::Fail,
600                "a:b:c::f".parse().unwrap(),
601                "mx.domain.org",
602                "otherdomain.org",
603                "sender@otherdomain.org",
604            ),
605            (
606                concat!(
607                    "spf=neutral (mx.domain.org: domain of postmaster@example.org reports neutral ",
608                    "for a:b:c::f) smtp.mailfrom=<>"
609                ),
610                concat!(
611                    "neutral (mx.domain.org: domain of postmaster@example.org reports neutral for ",
612                    "a:b:c::f)\r\n\treceiver=mx.domain.org; client-ip=a:b:c::f; ",
613                    "envelope-from=\"postmaster@example.org\"; helo=example.org;"
614                ),
615                SpfResult::Neutral,
616                "a:b:c::f".parse().unwrap(),
617                "mx.domain.org",
618                "example.org",
619                "",
620            ),
621        ] {
622            auth_results.hostname = receiver;
623            auth_results = auth_results.with_spf_mailfrom_result(
624                &SpfOutput {
625                    result,
626                    domain: "".to_string(),
627                    report: None,
628                    explanation: None,
629                },
630                ip_addr,
631                mail_from,
632                helo,
633            );
634            let received_spf = ReceivedSpf::new(
635                &SpfOutput {
636                    result,
637                    domain: "".to_string(),
638                    report: None,
639                    explanation: None,
640                },
641                ip_addr,
642                helo,
643                mail_from,
644                receiver,
645            );
646            assert_eq!(
647                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
648                expected_auth_results
649            );
650            assert_eq!(received_spf.received_spf, expected_received_spf);
651        }
652
653        for (expected_auth_results, dmarc) in [
654            (
655                "dmarc=pass header.from=example.org policy.dmarc=none",
656                DmarcOutput {
657                    spf_result: DmarcResult::Pass,
658                    dkim_result: DmarcResult::None,
659                    domain: "example.org".to_string(),
660                    policy: Policy::None,
661                    record: None,
662                },
663            ),
664            (
665                "dmarc=fail (policy not aligned) header.from=example.com policy.dmarc=quarantine",
666                DmarcOutput {
667                    dkim_result: DmarcResult::Fail(Error::NotAligned),
668                    spf_result: DmarcResult::None,
669                    domain: "example.com".to_string(),
670                    policy: Policy::Quarantine,
671                    record: None,
672                },
673            ),
674        ] {
675            auth_results = auth_results.with_dmarc_result(&dmarc);
676            assert_eq!(
677                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
678                expected_auth_results
679            );
680        }
681
682        #[cfg(feature = "arc")]
683        for (expected_auth_results, arc, remote_ip) in [
684            (
685                "arc=pass smtp.remote-ip=192.127.9.2",
686                DkimResult::Pass,
687                "192.127.9.2".parse().unwrap(),
688            ),
689            (
690                "arc=neutral (body hash did not verify) smtp.remote-ip=\"1:2:3::a\"",
691                DkimResult::Neutral(Error::Arc(ArcError::FailedBodyHashMatch)),
692                "1:2:3::a".parse().unwrap(),
693            ),
694        ] {
695            auth_results = auth_results.with_arc_result(
696                &ArcOutput {
697                    result: arc,
698                    set: vec![],
699                },
700                remote_ip,
701            );
702            assert_eq!(
703                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
704                expected_auth_results
705            );
706        }
707
708        for (expected_auth_results, iprev, remote_ip) in [
709            (
710                "iprev=pass policy.iprev=192.127.9.2",
711                IprevOutput {
712                    result: IprevResult::Pass,
713                    ptr: None,
714                },
715                "192.127.9.2".parse().unwrap(),
716            ),
717            (
718                "iprev=fail (policy not aligned) policy.iprev=\"1:2:3::a\"",
719                IprevOutput {
720                    result: IprevResult::Fail(Error::NotAligned),
721                    ptr: None,
722                },
723                "1:2:3::a".parse().unwrap(),
724            ),
725        ] {
726            auth_results = auth_results.with_iprev_result(&iprev, remote_ip);
727            assert_eq!(
728                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
729                expected_auth_results
730            );
731        }
732    }
733
734    #[test]
735    fn dkim2_authentication_results() {
736        use crate::{
737            Dkim2Result,
738            dkim2::{ChainLink, Dkim2Error, Dkim2Output, Signature as Dkim2Signature},
739        };
740
741        let originator = Dkim2Signature {
742            i: 1,
743            d: "example.org".into(),
744            ..Default::default()
745        };
746        let relay = Dkim2Signature {
747            i: 2,
748            d: "relay.example.com".into(),
749            ..Default::default()
750        };
751
752        let pass = Dkim2Output {
753            result: Dkim2Result::Pass,
754            chain: vec![
755                ChainLink {
756                    signature: &originator,
757                    instance: None,
758                    result: Dkim2Result::Pass,
759                    custody_ok: true,
760                },
761                ChainLink {
762                    signature: &relay,
763                    instance: None,
764                    result: Dkim2Result::Pass,
765                    custody_ok: true,
766                },
767            ],
768        };
769
770        let fail = Dkim2Output {
771            result: Dkim2Result::Fail(Error::Dkim2(Dkim2Error::BodyHashMismatch(2))),
772            chain: vec![
773                ChainLink {
774                    signature: &originator,
775                    instance: None,
776                    result: Dkim2Result::Pass,
777                    custody_ok: true,
778                },
779                ChainLink {
780                    signature: &relay,
781                    instance: None,
782                    result: Dkim2Result::Fail(Error::Dkim2(Dkim2Error::BodyHashMismatch(2))),
783                    custody_ok: true,
784                },
785            ],
786        };
787
788        let permerror: Dkim2Output =
789            Dkim2Result::PermError(Error::Dkim2(Dkim2Error::SignatureMissing(1))).into();
790
791        let none: Dkim2Output = Dkim2Result::None.into();
792
793        for (expected, output) in [
794            ("dkim2=pass header.d=example.org header.i=1", &pass),
795            (
796                concat!(
797                    "dkim2=fail (Message-Instance m=2 body hash mismatch) ",
798                    "header.d=relay.example.com header.i=2"
799                ),
800                &fail,
801            ),
802            ("dkim2=permerror (DKIM2-Signature i=1 missing)", &permerror),
803            ("dkim2=none", &none),
804        ] {
805            let auth_results = AuthenticationResults::new("mydomain.org").with_dkim2_result(output);
806            assert_eq!(
807                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
808                expected
809            );
810        }
811    }
812
813    #[test]
814    fn dkim_result_header_injection() {
815        let signature = Signature {
816            i: "u@evil.test\r\nReply-To: attacker@evil.test\r\nX-Injected: yes".into(),
817            d: "evil.test\r\nX-Injected-D: yes".into(),
818            s: "sel\r\nX-Injected-S: yes".into(),
819            b: b"123456".to_vec(),
820            ..Default::default()
821        };
822        let output = DkimOutput {
823            result: DkimResult::Fail(Error::Crypto(CryptoError::FailedVerification)),
824            signature: Some(&signature),
825            report: None,
826            is_atps: false,
827        };
828        let auth_results = AuthenticationResults::new("mx.example.org")
829            .with_dkim_result(&output, "from@example.org");
830
831        assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
832        let value = auth_results.auth_results.split_once("header.i=").unwrap().1;
833        assert!(!value.contains('\r') && !value.contains('\n'));
834        assert!(value.starts_with("\"u@evil.test"));
835        assert!(value.contains("Reply-To: attacker@evil.test"));
836    }
837
838    #[test]
839    fn dkim_result_header_i_quoted_local_part() {
840        let signature = Signature {
841            i: "a;b=c (note)\"x@example.org".into(),
842            d: "example.org".into(),
843            s: "sel".into(),
844            ..Default::default()
845        };
846        let output = DkimOutput {
847            result: DkimResult::Pass,
848            signature: Some(&signature),
849            report: None,
850            is_atps: false,
851        };
852        let auth_results = AuthenticationResults::new("mx.example.org")
853            .with_dkim_result(&output, "from@example.org");
854        let value = auth_results.auth_results.split_once("header.i=").unwrap().1;
855
856        assert!(value.starts_with("\"a;b=c (note)\\\"x@example.org\""));
857        assert_eq!(value.matches('"').count(), 3);
858    }
859
860    #[test]
861    fn dkim_result_header_d_injection() {
862        let signature = Signature {
863            d: "evil.test\r\nX-Injected: yes".into(),
864            s: "sel\"; smtp.bogus=1".into(),
865            ..Default::default()
866        };
867        let output = DkimOutput {
868            result: DkimResult::Fail(Error::Crypto(CryptoError::FailedVerification)),
869            signature: Some(&signature),
870            report: None,
871            is_atps: false,
872        };
873        let auth_results = AuthenticationResults::new("mx.example.org")
874            .with_dkim_result(&output, "from@example.org");
875
876        assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
877        let value = auth_results.auth_results.split_once("header.d=").unwrap().1;
878        assert!(!value.contains('\r') && !value.contains('\n'));
879        assert!(!value.contains('"') && !value.contains(';'));
880    }
881
882    #[test]
883    fn spf_result_header_injection() {
884        let spf = SpfOutput {
885            result: SpfResult::Pass,
886            domain: String::new(),
887            report: None,
888            explanation: None,
889        };
890        let auth_results = AuthenticationResults::new("mx.example.org").with_spf_mailfrom_result(
891            &spf,
892            "192.168.1.1".parse().unwrap(),
893            "a@evil.test\r\nX-Injected: yes",
894            "helo.test\r\nX-Injected-Helo: yes",
895        );
896        assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
897
898        let auth_results = AuthenticationResults::new("mx.example.org").with_spf_ehlo_result(
899            &spf,
900            "192.168.1.1".parse().unwrap(),
901            "helo.test\r\nX-Injected: yes",
902        );
903        assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
904    }
905
906    #[test]
907    fn dmarc_result_header_injection() {
908        let auth_results =
909            AuthenticationResults::new("mx.example.org").with_dmarc_result(&DmarcOutput {
910                spf_result: DmarcResult::Pass,
911                dkim_result: DmarcResult::None,
912                domain: "evil.test\r\nX-Injected: yes".to_string(),
913                policy: Policy::None,
914                record: None,
915            });
916        assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
917        let value = auth_results
918            .auth_results
919            .split_once("header.from=")
920            .unwrap()
921            .1;
922        assert!(!value.contains('\r') && !value.contains('\n'));
923    }
924
925    #[test]
926    fn received_spf_header_injection() {
927        let spf = SpfOutput {
928            result: SpfResult::Pass,
929            domain: String::new(),
930            report: None,
931            explanation: None,
932        };
933        let received_spf = ReceivedSpf::new(
934            &spf,
935            "192.168.1.1".parse().unwrap(),
936            "helo.test\r\nX-Injected-Helo: yes",
937            "a@evil.test\r\nX-Injected: yes\r\nReply-To: attacker@evil.test",
938            "mx.example.org",
939        );
940        assert_eq!(received_spf.received_spf.matches("\r\n").count(), 1);
941        assert!(
942            !received_spf.received_spf.contains('"')
943                || received_spf.received_spf.matches('"').count() == 2
944        );
945    }
946}