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