1use 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
361fn 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}