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