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};
8use crate::{
9    ArcOutput, AuthenticationResults, DkimOutput, DkimResult, DmarcOutput, DmarcResult, Error,
10    IprevOutput, IprevResult, ReceivedSpf, SpfOutput, SpfResult,
11};
12use mail_builder::encoders::base64::base64_encode;
13use std::{
14    borrow::Cow,
15    fmt::{Display, Write},
16    net::IpAddr,
17};
18
19impl<'x> AuthenticationResults<'x> {
20    pub fn new(hostname: &'x str) -> Self {
21        AuthenticationResults {
22            hostname,
23            auth_results: String::with_capacity(64),
24        }
25    }
26
27    pub fn with_dkim_results(mut self, dkim: &[DkimOutput], header_from: &str) -> Self {
28        for dkim in dkim {
29            self.set_dkim_result(dkim, header_from);
30        }
31        self
32    }
33
34    pub fn with_dkim_result(mut self, dkim: &DkimOutput, header_from: &str) -> Self {
35        self.set_dkim_result(dkim, header_from);
36        self
37    }
38
39    pub fn set_dkim_result(&mut self, dkim: &DkimOutput, header_from: &str) {
40        if !dkim.is_atps {
41            self.auth_results.push_str(";\r\n\tdkim=");
42        } else {
43            self.auth_results.push_str(";\r\n\tdkim-atps=");
44        }
45        dkim.result.as_auth_result(&mut self.auth_results);
46        if let Some(signature) = &dkim.signature {
47            if !signature.i.is_empty() {
48                self.auth_results.push_str(" header.i=");
49                push_quoted_pvalue(&mut self.auth_results, &signature.i);
50            } else {
51                self.auth_results.push_str(" header.d=");
52                push_pvalue(&mut self.auth_results, &signature.d);
53            }
54            self.auth_results.push_str(" header.s=");
55            push_pvalue(&mut self.auth_results, &signature.s);
56            if signature.b.len() >= 6 {
57                self.auth_results.push_str(" header.b=");
58                self.auth_results.push_str(
59                    &String::from_utf8(base64_encode(&signature.b[..6]).unwrap_or_default())
60                        .unwrap_or_default(),
61                );
62            }
63        }
64
65        if dkim.is_atps {
66            self.auth_results.push_str(" header.from=");
67            push_quoted_pvalue(&mut self.auth_results, header_from);
68        }
69    }
70
71    pub fn with_spf_ehlo_result(
72        mut self,
73        spf: &SpfOutput,
74        ip_addr: IpAddr,
75        ehlo_domain: &str,
76    ) -> Self {
77        let ehlo_domain = sanitize_pvalue(ehlo_domain);
78        self.auth_results.push_str(";\r\n\tspf=");
79        spf.result.as_spf_result(
80            &mut self.auth_results,
81            self.hostname,
82            &format!("postmaster@{ehlo_domain}"),
83            ip_addr,
84        );
85        write!(self.auth_results, " smtp.helo={ehlo_domain}").ok();
86        self
87    }
88
89    pub fn with_spf_mailfrom_result(
90        mut self,
91        spf: &SpfOutput,
92        ip_addr: IpAddr,
93        from: &str,
94        ehlo_domain: &str,
95    ) -> Self {
96        let ehlo_domain = sanitize_pvalue(ehlo_domain);
97        let mail_from = if !from.is_empty() {
98            sanitize_pvalue(from)
99        } else {
100            Cow::Owned(format!("postmaster@{ehlo_domain}"))
101        };
102        self.auth_results.push_str(";\r\n\tspf=");
103        spf.result.as_spf_result(
104            &mut self.auth_results,
105            self.hostname,
106            mail_from.as_ref(),
107            ip_addr,
108        );
109        self.auth_results.push_str(" smtp.mailfrom=");
110        if !from.is_empty() {
111            push_quoted_pvalue(&mut self.auth_results, from);
112        } else {
113            self.auth_results.push_str("<>");
114        }
115        self
116    }
117
118    pub fn with_arc_result(mut self, arc: &ArcOutput, remote_ip: IpAddr) -> Self {
119        self.auth_results.push_str(";\r\n\tarc=");
120        arc.result.as_auth_result(&mut self.auth_results);
121        let _ = write!(self.auth_results, " smtp.remote-ip=");
122        let _ = format_ip_as_pvalue(&mut self.auth_results, remote_ip);
123        self
124    }
125
126    pub fn with_dmarc_result(mut self, dmarc: &DmarcOutput) -> Self {
127        self.auth_results.push_str(";\r\n\tdmarc=");
128        if dmarc.spf_result == DmarcResult::Pass || dmarc.dkim_result == DmarcResult::Pass {
129            DmarcResult::Pass.as_auth_result(&mut self.auth_results);
130        } else if dmarc.spf_result != DmarcResult::None {
131            dmarc.spf_result.as_auth_result(&mut self.auth_results);
132        } else if dmarc.dkim_result != DmarcResult::None {
133            dmarc.dkim_result.as_auth_result(&mut self.auth_results);
134        } else {
135            DmarcResult::None.as_auth_result(&mut self.auth_results);
136        }
137        write!(
138            self.auth_results,
139            " header.from={} policy.dmarc={}",
140            sanitize_pvalue(&dmarc.domain),
141            dmarc.policy
142        )
143        .ok();
144        self
145    }
146
147    pub fn with_iprev_result(mut self, iprev: &IprevOutput, remote_ip: IpAddr) -> Self {
148        self.auth_results.push_str(";\r\n\tiprev=");
149        iprev.result.as_auth_result(&mut self.auth_results);
150        let _ = write!(self.auth_results, " policy.iprev=");
151        let _ = format_ip_as_pvalue(&mut self.auth_results, remote_ip);
152        self
153    }
154}
155
156impl Display for AuthenticationResults<'_> {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        f.write_str(self.hostname)?;
159        f.write_str(&self.auth_results)
160    }
161}
162
163impl HeaderWriter for AuthenticationResults<'_> {
164    fn write_header(&self, writer: &mut impl Writer) {
165        writer.write(b"Authentication-Results: ");
166        writer.write(self.hostname.as_bytes());
167        if !self.auth_results.is_empty() {
168            writer.write(self.auth_results.as_bytes());
169        } else {
170            writer.write(b"; none");
171        }
172        writer.write(b"\r\n");
173    }
174}
175
176impl HeaderWriter for ReceivedSpf {
177    fn write_header(&self, writer: &mut impl Writer) {
178        writer.write(b"Received-SPF: ");
179        writer.write(self.received_spf.as_bytes());
180        writer.write(b"\r\n");
181    }
182}
183
184impl ReceivedSpf {
185    pub fn new(
186        spf: &SpfOutput,
187        ip_addr: IpAddr,
188        helo: &str,
189        mail_from: &str,
190        hostname: &str,
191    ) -> Self {
192        let mut received_spf = String::with_capacity(64);
193        let helo = sanitize_pvalue(helo);
194        let envelope_from = if !mail_from.is_empty() {
195            Cow::Borrowed(mail_from)
196        } else {
197            Cow::Owned(format!("postmaster@{helo}"))
198        };
199        let mail_from = sanitize_pvalue(&envelope_from);
200
201        spf.result
202            .as_spf_result(&mut received_spf, hostname, mail_from.as_ref(), ip_addr);
203
204        write!(
205            received_spf,
206            "\r\n\treceiver={hostname}; client-ip={ip_addr}; envelope-from=\""
207        )
208        .ok();
209        push_qcontent(&mut received_spf, &envelope_from);
210        write!(received_spf, "\"; helo={helo};").ok();
211
212        ReceivedSpf { received_spf }
213    }
214}
215
216impl SpfResult {
217    fn as_spf_result(&self, header: &mut String, hostname: &str, mail_from: &str, ip_addr: IpAddr) {
218        match &self {
219            SpfResult::Pass => write!(
220                header,
221                "pass ({hostname}: domain of {mail_from} designates {ip_addr} as permitted sender)",
222            ),
223            SpfResult::Fail => write!(
224                header,
225                "fail ({hostname}: domain of {mail_from} does not designate {ip_addr} as permitted sender)",
226            ),
227            SpfResult::SoftFail => write!(
228                header,
229                "softfail ({hostname}: domain of {mail_from} reports soft fail for {ip_addr})",
230            ),
231            SpfResult::Neutral => write!(
232                header,
233                "neutral ({hostname}: domain of {mail_from} reports neutral for {ip_addr})",
234            ),
235            SpfResult::TempError => write!(
236                header,
237                "temperror ({hostname}: temporary dns error validating {mail_from})",
238            ),
239            SpfResult::PermError => write!(
240                header,
241                "permerror ({hostname}: unable to verify SPF record for {mail_from})",
242            ),
243            SpfResult::None => write!(
244                header,
245                "none ({hostname}: no SPF records found for {mail_from})",
246            ),
247        }
248        .ok();
249    }
250}
251
252pub trait AsAuthResult {
253    fn as_auth_result(&self, header: &mut String);
254}
255
256impl AsAuthResult for DmarcResult {
257    fn as_auth_result(&self, header: &mut String) {
258        match &self {
259            DmarcResult::Pass => header.push_str("pass"),
260            DmarcResult::Fail(err) => {
261                header.push_str("fail");
262                err.as_auth_result(header);
263            }
264            DmarcResult::PermError(err) => {
265                header.push_str("permerror");
266                err.as_auth_result(header);
267            }
268            DmarcResult::TempError(err) => {
269                header.push_str("temperror");
270                err.as_auth_result(header);
271            }
272            DmarcResult::None => header.push_str("none"),
273        }
274    }
275}
276
277impl AsAuthResult for IprevResult {
278    fn as_auth_result(&self, header: &mut String) {
279        match &self {
280            IprevResult::Pass => header.push_str("pass"),
281            IprevResult::Fail(err) => {
282                header.push_str("fail");
283                err.as_auth_result(header);
284            }
285            IprevResult::PermError(err) => {
286                header.push_str("permerror");
287                err.as_auth_result(header);
288            }
289            IprevResult::TempError(err) => {
290                header.push_str("temperror");
291                err.as_auth_result(header);
292            }
293            IprevResult::None => header.push_str("none"),
294        }
295    }
296}
297
298impl AsAuthResult for DkimResult {
299    fn as_auth_result(&self, header: &mut String) {
300        match &self {
301            DkimResult::Pass => header.push_str("pass"),
302            DkimResult::Neutral(err) => {
303                header.push_str("neutral");
304                err.as_auth_result(header);
305            }
306            DkimResult::Fail(err) => {
307                header.push_str("fail");
308                err.as_auth_result(header);
309            }
310            DkimResult::PermError(err) => {
311                header.push_str("permerror");
312                err.as_auth_result(header);
313            }
314            DkimResult::TempError(err) => {
315                header.push_str("temperror");
316                err.as_auth_result(header);
317            }
318            DkimResult::None => header.push_str("none"),
319        }
320    }
321}
322
323impl AsAuthResult for Error {
324    fn as_auth_result(&self, header: &mut String) {
325        header.push_str(" (");
326        header.push_str(match self {
327            Error::ParseError => "dns record parse error",
328            Error::MissingParameters => "missing parameters",
329            Error::NoHeadersFound => "no headers found",
330            Error::CryptoError(_) => "verification failed",
331            Error::Io(_) => "i/o error",
332            Error::Base64 => "base64 error",
333            Error::UnsupportedVersion => "unsupported version",
334            Error::UnsupportedAlgorithm => "unsupported algorithm",
335            Error::UnsupportedCanonicalization => "unsupported canonicalization",
336            Error::UnsupportedKeyType => "unsupported key type",
337            Error::FailedBodyHashMatch => "body hash did not verify",
338            Error::FailedVerification => "verification failed",
339            Error::FailedAuidMatch => "auid does not match",
340            Error::RevokedPublicKey => "revoked public key",
341            Error::IncompatibleAlgorithms => "incompatible record/signature algorithms",
342            Error::SignatureExpired => "signature error",
343            Error::DnsError(_) => "dns error",
344            Error::DnsRecordNotFound(_) => "dns record not found",
345            Error::ArcInvalidInstance(i) => {
346                write!(header, "invalid ARC instance {i})").ok();
347                return;
348            }
349            Error::ArcInvalidCV => "invalid ARC cv",
350            Error::ArcChainTooLong => "too many ARC headers",
351            Error::ArcHasHeaderTag => "ARC has header tag",
352            Error::ArcBrokenChain => "broken ARC chain",
353            Error::NotAligned => "policy not aligned",
354            Error::InvalidRecordType => "invalid dns record type",
355            Error::SignatureLength => "signature length ignored due to security risk",
356        });
357        header.push(')');
358    }
359}
360
361/// Encodes the IP address to be used in a [`pvalue`] field.
362///
363/// IPv4 addresses can be used as-is, but IPv6 addresses need to be quoted
364/// since they contain `:` characters.
365///
366/// [`pvalue`]: https://datatracker.ietf.org/doc/html/rfc8601#section-2.2
367fn format_ip_as_pvalue(w: &mut impl Write, ip: IpAddr) -> std::fmt::Result {
368    match ip {
369        IpAddr::V4(addr) => write!(w, "{addr}"),
370        IpAddr::V6(addr) => write!(w, "\"{addr}\""),
371    }
372}
373
374#[inline]
375fn is_pvalue_safe(ch: char) -> bool {
376    !matches!(ch, '\0'..=' ' | '\u{7f}'..='\u{9f}' | '(' | ')' | ';' | '=' | '"' | '\\')
377}
378
379#[inline]
380fn sanitize_pvalue(value: &str) -> Cow<'_, str> {
381    if value.chars().all(is_pvalue_safe) {
382        Cow::Borrowed(value)
383    } else {
384        Cow::Owned(value.chars().filter(|&ch| is_pvalue_safe(ch)).collect())
385    }
386}
387
388#[inline]
389fn push_pvalue(header: &mut String, value: &str) {
390    header.extend(value.chars().filter(|&ch| is_pvalue_safe(ch)));
391}
392
393#[inline]
394fn push_quoted_pvalue(header: &mut String, value: &str) {
395    if !value.is_empty() && value.chars().all(is_pvalue_safe) {
396        header.push_str(value);
397    } else {
398        header.push('"');
399        push_qcontent(header, value);
400        header.push('"');
401    }
402}
403
404#[inline]
405fn push_qcontent(header: &mut String, value: &str) {
406    for ch in value.chars() {
407        match ch {
408            '"' | '\\' => {
409                header.push('\\');
410                header.push(ch);
411            }
412            '\0'..='\u{1f}' | '\u{7f}'..='\u{9f}' => {}
413            ch => header.push(ch),
414        }
415    }
416}
417
418#[cfg(test)]
419mod test {
420    use crate::{
421        ArcOutput, AuthenticationResults, DkimOutput, DkimResult, DmarcOutput, DmarcResult, Error,
422        IprevOutput, IprevResult, ReceivedSpf, SpfOutput, SpfResult, dkim::Signature,
423        dmarc::Policy,
424    };
425
426    #[test]
427    fn authentication_results() {
428        let mut auth_results = AuthenticationResults::new("mydomain.org");
429
430        for (expected_auth_results, dkim) in [
431            (
432                "dkim=pass header.d=example.org header.s=myselector",
433                DkimOutput {
434                    result: DkimResult::Pass,
435                    signature: (&Signature {
436                        d: "example.org".into(),
437                        s: "myselector".into(),
438                        ..Default::default()
439                    })
440                        .into(),
441                    report: None,
442                    is_atps: false,
443                },
444            ),
445            (
446                concat!(
447                    "dkim=fail (verification failed) header.d=example.org ",
448                    "header.s=myselector header.b=MTIzNDU2"
449                ),
450                DkimOutput {
451                    result: DkimResult::Fail(Error::FailedVerification),
452                    signature: (&Signature {
453                        d: "example.org".into(),
454                        s: "myselector".into(),
455                        b: b"123456".to_vec(),
456                        ..Default::default()
457                    })
458                        .into(),
459                    report: None,
460                    is_atps: false,
461                },
462            ),
463            (
464                concat!(
465                    "dkim-atps=temperror (dns error) header.d=atps.example.org ",
466                    "header.s=otherselctor header.b=YWJjZGVm header.from=jdoe@example.org"
467                ),
468                DkimOutput {
469                    result: DkimResult::TempError(Error::DnsError("".to_string())),
470                    signature: (&Signature {
471                        d: "atps.example.org".into(),
472                        s: "otherselctor".into(),
473                        b: b"abcdef".to_vec(),
474                        ..Default::default()
475                    })
476                        .into(),
477                    report: None,
478                    is_atps: true,
479                },
480            ),
481        ] {
482            auth_results = auth_results.with_dkim_results(&[dkim], "jdoe@example.org");
483            assert_eq!(
484                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
485                expected_auth_results
486            );
487        }
488
489        for (
490            expected_auth_results,
491            expected_received_spf,
492            result,
493            ip_addr,
494            receiver,
495            helo,
496            mail_from,
497        ) in [
498            (
499                concat!(
500                    "spf=pass (localhost: domain of jdoe@example.org designates 192.168.1.1 ",
501                    "as permitted sender) smtp.mailfrom=jdoe@example.org"
502                ),
503                concat!(
504                    "pass (localhost: domain of jdoe@example.org designates 192.168.1.1 as ",
505                    "permitted sender)\r\n\treceiver=localhost; client-ip=192.168.1.1; ",
506                    "envelope-from=\"jdoe@example.org\"; helo=example.org;"
507                ),
508                SpfResult::Pass,
509                "192.168.1.1".parse().unwrap(),
510                "localhost",
511                "example.org",
512                "jdoe@example.org",
513            ),
514            (
515                concat!(
516                    "spf=fail (mx.domain.org: domain of sender@otherdomain.org does not ",
517                    "designate a:b:c::f as permitted sender) smtp.mailfrom=sender@otherdomain.org"
518                ),
519                concat!(
520                    "fail (mx.domain.org: domain of sender@otherdomain.org does not designate ",
521                    "a:b:c::f as permitted sender)\r\n\treceiver=mx.domain.org; ",
522                    "client-ip=a:b:c::f; envelope-from=\"sender@otherdomain.org\"; ",
523                    "helo=otherdomain.org;"
524                ),
525                SpfResult::Fail,
526                "a:b:c::f".parse().unwrap(),
527                "mx.domain.org",
528                "otherdomain.org",
529                "sender@otherdomain.org",
530            ),
531            (
532                concat!(
533                    "spf=neutral (mx.domain.org: domain of postmaster@example.org reports neutral ",
534                    "for a:b:c::f) smtp.mailfrom=<>"
535                ),
536                concat!(
537                    "neutral (mx.domain.org: domain of postmaster@example.org reports neutral for ",
538                    "a:b:c::f)\r\n\treceiver=mx.domain.org; client-ip=a:b:c::f; ",
539                    "envelope-from=\"postmaster@example.org\"; helo=example.org;"
540                ),
541                SpfResult::Neutral,
542                "a:b:c::f".parse().unwrap(),
543                "mx.domain.org",
544                "example.org",
545                "",
546            ),
547        ] {
548            auth_results.hostname = receiver;
549            auth_results = auth_results.with_spf_mailfrom_result(
550                &SpfOutput {
551                    result,
552                    domain: "".to_string(),
553                    report: None,
554                    explanation: None,
555                },
556                ip_addr,
557                mail_from,
558                helo,
559            );
560            let received_spf = ReceivedSpf::new(
561                &SpfOutput {
562                    result,
563                    domain: "".to_string(),
564                    report: None,
565                    explanation: None,
566                },
567                ip_addr,
568                helo,
569                mail_from,
570                receiver,
571            );
572            assert_eq!(
573                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
574                expected_auth_results
575            );
576            assert_eq!(received_spf.received_spf, expected_received_spf);
577        }
578
579        for (expected_auth_results, dmarc) in [
580            (
581                "dmarc=pass header.from=example.org policy.dmarc=none",
582                DmarcOutput {
583                    spf_result: DmarcResult::Pass,
584                    dkim_result: DmarcResult::None,
585                    domain: "example.org".to_string(),
586                    policy: Policy::None,
587                    record: None,
588                },
589            ),
590            (
591                "dmarc=fail (policy not aligned) header.from=example.com policy.dmarc=quarantine",
592                DmarcOutput {
593                    dkim_result: DmarcResult::Fail(Error::NotAligned),
594                    spf_result: DmarcResult::None,
595                    domain: "example.com".to_string(),
596                    policy: Policy::Quarantine,
597                    record: None,
598                },
599            ),
600        ] {
601            auth_results = auth_results.with_dmarc_result(&dmarc);
602            assert_eq!(
603                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
604                expected_auth_results
605            );
606        }
607
608        for (expected_auth_results, arc, remote_ip) in [
609            (
610                "arc=pass smtp.remote-ip=192.127.9.2",
611                DkimResult::Pass,
612                "192.127.9.2".parse().unwrap(),
613            ),
614            (
615                "arc=neutral (body hash did not verify) smtp.remote-ip=\"1:2:3::a\"",
616                DkimResult::Neutral(Error::FailedBodyHashMatch),
617                "1:2:3::a".parse().unwrap(),
618            ),
619        ] {
620            auth_results = auth_results.with_arc_result(
621                &ArcOutput {
622                    result: arc,
623                    set: vec![],
624                },
625                remote_ip,
626            );
627            assert_eq!(
628                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
629                expected_auth_results
630            );
631        }
632
633        for (expected_auth_results, iprev, remote_ip) in [
634            (
635                "iprev=pass policy.iprev=192.127.9.2",
636                IprevOutput {
637                    result: IprevResult::Pass,
638                    ptr: None,
639                },
640                "192.127.9.2".parse().unwrap(),
641            ),
642            (
643                "iprev=fail (policy not aligned) policy.iprev=\"1:2:3::a\"",
644                IprevOutput {
645                    result: IprevResult::Fail(Error::NotAligned),
646                    ptr: None,
647                },
648                "1:2:3::a".parse().unwrap(),
649            ),
650        ] {
651            auth_results = auth_results.with_iprev_result(&iprev, remote_ip);
652            assert_eq!(
653                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
654                expected_auth_results
655            );
656        }
657    }
658
659    #[test]
660    fn dkim_result_header_injection() {
661        let signature = Signature {
662            i: "u@evil.test\r\nReply-To: attacker@evil.test\r\nX-Injected: yes".into(),
663            d: "evil.test\r\nX-Injected-D: yes".into(),
664            s: "sel\r\nX-Injected-S: yes".into(),
665            b: b"123456".to_vec(),
666            ..Default::default()
667        };
668        let output = DkimOutput {
669            result: DkimResult::Fail(Error::FailedVerification),
670            signature: Some(&signature),
671            report: None,
672            is_atps: false,
673        };
674        let auth_results = AuthenticationResults::new("mx.example.org")
675            .with_dkim_result(&output, "from@example.org");
676
677        assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
678        let value = auth_results.auth_results.split_once("header.i=").unwrap().1;
679        assert!(!value.contains('\r') && !value.contains('\n'));
680        assert!(value.starts_with("\"u@evil.test"));
681        assert!(value.contains("Reply-To: attacker@evil.test"));
682    }
683
684    #[test]
685    fn dkim_result_header_i_quoted_local_part() {
686        let signature = Signature {
687            i: "a;b=c (note)\"x@example.org".into(),
688            d: "example.org".into(),
689            s: "sel".into(),
690            ..Default::default()
691        };
692        let output = DkimOutput {
693            result: DkimResult::Pass,
694            signature: Some(&signature),
695            report: None,
696            is_atps: false,
697        };
698        let auth_results = AuthenticationResults::new("mx.example.org")
699            .with_dkim_result(&output, "from@example.org");
700        let value = auth_results.auth_results.split_once("header.i=").unwrap().1;
701
702        assert!(value.starts_with("\"a;b=c (note)\\\"x@example.org\""));
703        assert_eq!(value.matches('"').count(), 3);
704    }
705
706    #[test]
707    fn dkim_result_header_d_injection() {
708        let signature = Signature {
709            d: "evil.test\r\nX-Injected: yes".into(),
710            s: "sel\"; smtp.bogus=1".into(),
711            ..Default::default()
712        };
713        let output = DkimOutput {
714            result: DkimResult::Fail(Error::FailedVerification),
715            signature: Some(&signature),
716            report: None,
717            is_atps: false,
718        };
719        let auth_results = AuthenticationResults::new("mx.example.org")
720            .with_dkim_result(&output, "from@example.org");
721
722        assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
723        let value = auth_results.auth_results.split_once("header.d=").unwrap().1;
724        assert!(!value.contains('\r') && !value.contains('\n'));
725        assert!(!value.contains('"') && !value.contains(';'));
726    }
727
728    #[test]
729    fn spf_result_header_injection() {
730        let spf = SpfOutput {
731            result: SpfResult::Pass,
732            domain: String::new(),
733            report: None,
734            explanation: None,
735        };
736        let auth_results = AuthenticationResults::new("mx.example.org").with_spf_mailfrom_result(
737            &spf,
738            "192.168.1.1".parse().unwrap(),
739            "a@evil.test\r\nX-Injected: yes",
740            "helo.test\r\nX-Injected-Helo: yes",
741        );
742        assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
743
744        let auth_results = AuthenticationResults::new("mx.example.org").with_spf_ehlo_result(
745            &spf,
746            "192.168.1.1".parse().unwrap(),
747            "helo.test\r\nX-Injected: yes",
748        );
749        assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
750    }
751
752    #[test]
753    fn dmarc_result_header_injection() {
754        let auth_results =
755            AuthenticationResults::new("mx.example.org").with_dmarc_result(&DmarcOutput {
756                spf_result: DmarcResult::Pass,
757                dkim_result: DmarcResult::None,
758                domain: "evil.test\r\nX-Injected: yes".to_string(),
759                policy: Policy::None,
760                record: None,
761            });
762        assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
763        let value = auth_results
764            .auth_results
765            .split_once("header.from=")
766            .unwrap()
767            .1;
768        assert!(!value.contains('\r') && !value.contains('\n'));
769    }
770
771    #[test]
772    fn received_spf_header_injection() {
773        let spf = SpfOutput {
774            result: SpfResult::Pass,
775            domain: String::new(),
776            report: None,
777            explanation: None,
778        };
779        let received_spf = ReceivedSpf::new(
780            &spf,
781            "192.168.1.1".parse().unwrap(),
782            "helo.test\r\nX-Injected-Helo: yes",
783            "a@evil.test\r\nX-Injected: yes\r\nReply-To: attacker@evil.test",
784            "mx.example.org",
785        );
786        assert_eq!(received_spf.received_spf.matches("\r\n").count(), 1);
787        assert!(
788            !received_spf.received_spf.contains('"')
789                || received_spf.received_spf.matches('"').count() == 2
790        );
791    }
792}