1use super::headers::{HeaderWriter, IntegerBuffer, 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, dmarc::Policy};
16use mail_builder::encoders::base64::base64_encode_slice;
17use std::{
18 borrow::Cow,
19 fmt::{Display, Write},
20 net::{IpAddr, Ipv4Addr},
21};
22
23impl<'x> AuthenticationResults<'x> {
24 pub fn new(hostname: &'x str) -> Self {
25 AuthenticationResults {
26 hostname,
27 auth_results: String::with_capacity(256),
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 let Some(prefix) = signature.b.get(..6) {
61 self.auth_results.push_str(" header.b=");
62 let mut encoded = [0u8; 8];
63 let len = base64_encode_slice(prefix, &mut encoded);
64 self.auth_results.push_str(
65 std::str::from_utf8(encoded.get(..len).unwrap_or_default()).unwrap_or_default(),
66 );
67 }
68 }
69
70 if dkim.is_atps {
71 self.auth_results.push_str(" header.from=");
72 push_quoted_pvalue(&mut self.auth_results, header_from);
73 }
74 }
75
76 pub fn with_dkim2_result(mut self, dkim2: &Dkim2Output) -> Self {
77 self.set_dkim2_result(dkim2);
78 self
79 }
80
81 pub fn set_dkim2_result(&mut self, dkim2: &Dkim2Output) {
82 self.auth_results.push_str(";\r\n\tdkim2=");
83 dkim2.result().as_auth_result(&mut self.auth_results);
84
85 let link = if matches!(dkim2.result(), Dkim2Result::Pass) {
86 dkim2.chain().first()
87 } else {
88 dkim2
89 .chain()
90 .iter()
91 .find(|link| !matches!(link.result, Dkim2Result::Pass))
92 .or_else(|| dkim2.chain().first())
93 };
94 if let Some(link) = link {
95 self.auth_results.push_str(" header.d=");
96 push_pvalue(&mut self.auth_results, &link.signature.d);
97 self.auth_results.push_str(" header.i=");
98 push_integer(&mut self.auth_results, link.signature.i as u64);
99 }
100 }
101
102 pub fn with_spf_ehlo_result(
103 mut self,
104 spf: &SpfOutput,
105 ip_addr: IpAddr,
106 ehlo_domain: &str,
107 ) -> Self {
108 let ehlo_domain = sanitize_pvalue(ehlo_domain);
109 self.auth_results.push_str(";\r\n\tspf=");
110 spf.result.as_spf_result(
111 &mut self.auth_results,
112 self.hostname,
113 [POSTMASTER_AT, ehlo_domain.as_ref()],
114 ip_addr,
115 );
116 self.auth_results.push_str(" smtp.helo=");
117 self.auth_results.push_str(ehlo_domain.as_ref());
118 self
119 }
120
121 pub fn with_spf_mailfrom_result(
122 mut self,
123 spf: &SpfOutput,
124 ip_addr: IpAddr,
125 from: &str,
126 ehlo_domain: &str,
127 ) -> Self {
128 let ehlo_domain = sanitize_pvalue(ehlo_domain);
129 let sanitized_from = sanitize_pvalue(from);
130 let mail_from = if !from.is_empty() {
131 [sanitized_from.as_ref(), ""]
132 } else {
133 [POSTMASTER_AT, ehlo_domain.as_ref()]
134 };
135 self.auth_results.push_str(";\r\n\tspf=");
136 spf.result
137 .as_spf_result(&mut self.auth_results, self.hostname, mail_from, ip_addr);
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 self.auth_results.push_str(" smtp.remote-ip=");
152 push_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 match dmarc.mechanism_result() {
159 Some(result) => result.as_auth_result(&mut self.auth_results),
160 None => dmarc.result().as_auth_result(&mut self.auth_results),
161 }
162 self.auth_results.push_str(" header.from=");
163 push_pvalue(&mut self.auth_results, &dmarc.domain);
164 self.auth_results.push_str(match dmarc.policy {
165 Policy::Quarantine => " policy.dmarc=quarantine",
166 Policy::Reject => " policy.dmarc=reject",
167 Policy::None | Policy::Unspecified => " policy.dmarc=none",
168 });
169 self
170 }
171
172 pub fn with_iprev_result(mut self, iprev: &IprevOutput, remote_ip: IpAddr) -> Self {
173 self.auth_results.push_str(";\r\n\tiprev=");
174 iprev.result.as_auth_result(&mut self.auth_results);
175 self.auth_results.push_str(" policy.iprev=");
176 push_ip_as_pvalue(&mut self.auth_results, remote_ip);
177 self
178 }
179}
180
181impl Display for AuthenticationResults<'_> {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 f.write_str(self.hostname)?;
184 f.write_str(&self.auth_results)
185 }
186}
187
188impl HeaderWriter for AuthenticationResults<'_> {
189 fn write_header(&self, writer: &mut impl Writer) {
190 writer.write(b"Authentication-Results: ");
191 writer.write(self.hostname.as_bytes());
192 if !self.auth_results.is_empty() {
193 writer.write(self.auth_results.as_bytes());
194 } else {
195 writer.write(b"; none");
196 }
197 writer.write(b"\r\n");
198 }
199}
200
201impl HeaderWriter for ReceivedSpf {
202 fn write_header(&self, writer: &mut impl Writer) {
203 writer.write(b"Received-SPF: ");
204 writer.write(self.received_spf.as_bytes());
205 writer.write(b"\r\n");
206 }
207}
208
209impl ReceivedSpf {
210 pub fn new(
211 spf: &SpfOutput,
212 ip_addr: IpAddr,
213 helo: &str,
214 mail_from: &str,
215 hostname: &str,
216 ) -> Self {
217 let mut received_spf = String::with_capacity(256);
218 let helo = sanitize_pvalue(helo);
219 let sanitized_from = sanitize_pvalue(mail_from);
220 let envelope_from = if !mail_from.is_empty() {
221 [mail_from, ""]
222 } else {
223 [POSTMASTER_AT, helo.as_ref()]
224 };
225 let pieces = if !mail_from.is_empty() {
226 [sanitized_from.as_ref(), ""]
227 } else {
228 [POSTMASTER_AT, helo.as_ref()]
229 };
230
231 spf.result
232 .as_spf_result(&mut received_spf, hostname, pieces, ip_addr);
233
234 received_spf.push_str("\r\n\treceiver=");
235 received_spf.push_str(hostname);
236 received_spf.push_str("; client-ip=");
237 push_ip(&mut received_spf, ip_addr);
238 received_spf.push_str("; envelope-from=\"");
239 for piece in envelope_from {
240 push_qcontent(&mut received_spf, piece);
241 }
242 received_spf.push_str("\"; helo=");
243 received_spf.push_str(helo.as_ref());
244 received_spf.push(';');
245
246 ReceivedSpf { received_spf }
247 }
248}
249
250const POSTMASTER_AT: &str = "postmaster@";
251const MAX_IP_TEXT_LEN: usize = 46;
252
253impl SpfResult {
254 fn as_spf_result(
255 &self,
256 header: &mut String,
257 hostname: &str,
258 mail_from: [&str; 2],
259 ip_addr: IpAddr,
260 ) {
261 let (result, reason, designation, close) = match self {
262 SpfResult::Pass => (
263 "pass (",
264 ": domain of ",
265 Some(" designates "),
266 " as permitted sender)",
267 ),
268 SpfResult::Fail => (
269 "fail (",
270 ": domain of ",
271 Some(" does not designate "),
272 " as permitted sender)",
273 ),
274 SpfResult::SoftFail => (
275 "softfail (",
276 ": domain of ",
277 Some(" reports soft fail for "),
278 ")",
279 ),
280 SpfResult::Neutral => (
281 "neutral (",
282 ": domain of ",
283 Some(" reports neutral for "),
284 ")",
285 ),
286 SpfResult::TempError => (
287 "temperror (",
288 ": temporary dns error validating ",
289 None,
290 ")",
291 ),
292 SpfResult::PermError => (
293 "permerror (",
294 ": unable to verify SPF record for ",
295 None,
296 ")",
297 ),
298 SpfResult::None => ("none (", ": no SPF records found for ", None, ")"),
299 };
300
301 let mail_from_len = mail_from[0].len() + mail_from[1].len();
302 header.reserve(
303 result.len()
304 + hostname.len()
305 + reason.len()
306 + mail_from_len
307 + close.len()
308 + designation.map_or(0, |text| text.len() + MAX_IP_TEXT_LEN),
309 );
310
311 header.push_str(result);
312 header.push_str(hostname);
313 header.push_str(reason);
314 for piece in mail_from {
315 header.push_str(piece);
316 }
317 if let Some(designation) = designation {
318 header.push_str(designation);
319 push_ip(header, ip_addr);
320 }
321 header.push_str(close);
322 }
323}
324
325pub trait AsAuthResult {
326 fn as_auth_result(&self, header: &mut String);
327}
328
329impl AsAuthResult for DmarcResult {
330 fn as_auth_result(&self, header: &mut String) {
331 match &self {
332 DmarcResult::Pass => header.push_str("pass"),
333 DmarcResult::Fail(err) => {
334 header.push_str("fail");
335 err.as_auth_result(header);
336 }
337 DmarcResult::PermError(err) => {
338 header.push_str("permerror");
339 err.as_auth_result(header);
340 }
341 DmarcResult::TempError(err) => {
342 header.push_str("temperror");
343 err.as_auth_result(header);
344 }
345 DmarcResult::None => header.push_str("none"),
346 }
347 }
348}
349
350impl AsAuthResult for IprevResult {
351 fn as_auth_result(&self, header: &mut String) {
352 match &self {
353 IprevResult::Pass => header.push_str("pass"),
354 IprevResult::Fail(err) => {
355 header.push_str("fail");
356 err.as_auth_result(header);
357 }
358 IprevResult::PermError(err) => {
359 header.push_str("permerror");
360 err.as_auth_result(header);
361 }
362 IprevResult::TempError(err) => {
363 header.push_str("temperror");
364 err.as_auth_result(header);
365 }
366 IprevResult::None => header.push_str("none"),
367 }
368 }
369}
370
371impl AsAuthResult for DkimResult {
372 fn as_auth_result(&self, header: &mut String) {
373 match &self {
374 DkimResult::Pass => header.push_str("pass"),
375 DkimResult::Neutral(err) => {
376 header.push_str("neutral");
377 err.as_auth_result(header);
378 }
379 DkimResult::Fail(err) => {
380 header.push_str("fail");
381 err.as_auth_result(header);
382 }
383 DkimResult::PermError(err) => {
384 header.push_str("permerror");
385 err.as_auth_result(header);
386 }
387 DkimResult::TempError(err) => {
388 header.push_str("temperror");
389 err.as_auth_result(header);
390 }
391 DkimResult::None => header.push_str("none"),
392 }
393 }
394}
395
396impl AsAuthResult for Dkim2Result {
397 fn as_auth_result(&self, header: &mut String) {
398 match &self {
399 Dkim2Result::Pass => header.push_str("pass"),
400 Dkim2Result::Fail(err) => {
401 header.push_str("fail");
402 err.as_auth_result(header);
403 }
404 Dkim2Result::PermError(err) => {
405 header.push_str("permerror");
406 err.as_auth_result(header);
407 }
408 Dkim2Result::TempError(err) => {
409 header.push_str("temperror");
410 err.as_auth_result(header);
411 }
412 Dkim2Result::None => header.push_str("none"),
413 }
414 }
415}
416
417impl AsAuthResult for Error {
418 fn as_auth_result(&self, header: &mut String) {
419 header.push_str(" (");
420 header.push_str(match self {
421 Error::ParseError => "dns record parse error",
422 Error::MissingParameters => "missing parameters",
423 Error::NoHeadersFound => "no headers found",
424 Error::Crypto(CryptoError::Library(_)) => "verification failed",
425 Error::Io(_) => "i/o error",
426 Error::Base64 => "base64 error",
427 Error::Dkim(DkimError::UnsupportedAlgorithm) => "unsupported algorithm",
428 Error::Dkim(DkimError::UnsupportedCanonicalization) => "unsupported canonicalization",
429 Error::Dkim(DkimError::UnsupportedKeyType) => "unsupported key type",
430 Error::Crypto(CryptoError::FailedVerification) => "verification failed",
431 Error::Crypto(CryptoError::IncompatibleAlgorithms) => {
432 "incompatible record/signature algorithms"
433 }
434 Error::Dns(DnsError::Resolver(_)) => "dns error",
435 Error::Dns(DnsError::RecordNotFound(_)) => "dns record not found",
436 Error::Dkim(DkimError::UnsupportedVersion) => "unsupported version",
437 Error::Dkim(DkimError::FailedBodyHashMatch) => "body hash did not verify",
438 #[cfg(feature = "arc")]
439 Error::Arc(ArcError::FailedBodyHashMatch) => "body hash did not verify",
440 Error::Dkim(DkimError::FailedAuidMatch) => "auid does not match",
441 Error::Dkim(DkimError::RevokedPublicKey) => "revoked public key",
442 Error::Dkim(DkimError::SignatureExpired) => "signature error",
443 #[cfg(feature = "arc")]
444 Error::Arc(ArcError::SignatureExpired) => "signature error",
445 Error::Dkim(DkimError::SignatureLength) => {
446 "signature length ignored due to security risk"
447 }
448 #[cfg(feature = "arc")]
449 Error::Arc(ArcError::SignatureLength) => {
450 "signature length ignored due to security risk"
451 }
452 #[cfg(feature = "arc")]
453 Error::Arc(ArcError::InvalidInstance(i)) => {
454 write!(header, "invalid ARC instance {i})").ok();
455 return;
456 }
457 #[cfg(feature = "arc")]
458 Error::Arc(ArcError::InvalidCV) => "invalid ARC cv",
459 #[cfg(feature = "arc")]
460 Error::Arc(ArcError::ChainTooLong) => "too many ARC headers",
461 #[cfg(feature = "arc")]
462 Error::Arc(ArcError::HasHeaderTag) => "ARC has header tag",
463 #[cfg(feature = "arc")]
464 Error::Arc(ArcError::BrokenChain) => "broken ARC chain",
465 Error::NotAligned => "policy not aligned",
466 Error::Dns(DnsError::InvalidRecordType) => "invalid dns record type",
467 Error::Dkim2(e) => {
468 write!(header, "{e})").ok();
469 return;
470 }
471 });
472 header.push(')');
473 }
474}
475
476fn push_ip_as_pvalue(header: &mut String, ip: IpAddr) {
483 match ip {
484 IpAddr::V4(addr) => push_ipv4(header, addr),
485 IpAddr::V6(addr) => {
486 header.push('"');
487 write!(header, "{addr}").ok();
488 header.push('"');
489 }
490 }
491}
492
493fn push_ip(header: &mut String, ip: IpAddr) {
494 match ip {
495 IpAddr::V4(addr) => push_ipv4(header, addr),
496 IpAddr::V6(addr) => {
497 write!(header, "{addr}").ok();
498 }
499 }
500}
501
502fn push_ipv4(header: &mut String, addr: Ipv4Addr) {
503 const MAX_IPV4_TEXT_LEN: usize = 15;
504
505 let mut text = [0u8; MAX_IPV4_TEXT_LEN];
506 let mut len = 0;
507 let mut push = |byte: u8| {
508 if let Some(slot) = text.get_mut(len) {
509 *slot = byte;
510 len += 1;
511 }
512 };
513
514 for (pos, octet) in addr.octets().into_iter().enumerate() {
515 if pos > 0 {
516 push(b'.');
517 }
518 if octet >= 100 {
519 push(b'0' + octet / 100);
520 }
521 if octet >= 10 {
522 push(b'0' + (octet / 10) % 10);
523 }
524 push(b'0' + octet % 10);
525 }
526
527 header.push_str(std::str::from_utf8(text.get(..len).unwrap_or_default()).unwrap_or_default());
528}
529
530fn push_integer(header: &mut String, value: u64) {
531 let mut integer = IntegerBuffer::new();
532 header.push_str(integer.text(value));
533}
534
535#[inline]
536fn is_pvalue_safe(ch: char) -> bool {
537 !matches!(ch, '\0'..=' ' | '\u{7f}'..='\u{9f}' | '(' | ')' | ';' | '=' | '"' | '\\')
538}
539
540#[inline(always)]
541fn is_pvalue_safe_ascii(ch: u8) -> bool {
542 !matches!(ch, 0..=b' ' | 0x7f..=u8::MAX | b'(' | b')' | b';' | b'=' | b'"' | b'\\')
543}
544
545#[inline]
546fn is_pvalue_clean(value: &str) -> bool {
547 value.bytes().all(is_pvalue_safe_ascii) || value.chars().all(is_pvalue_safe)
548}
549
550#[inline]
551fn sanitize_pvalue(value: &str) -> Cow<'_, str> {
552 if is_pvalue_clean(value) {
553 Cow::Borrowed(value)
554 } else {
555 Cow::Owned(value.chars().filter(|&ch| is_pvalue_safe(ch)).collect())
556 }
557}
558
559#[inline]
560fn push_pvalue(header: &mut String, value: &str) {
561 if is_pvalue_clean(value) {
562 header.push_str(value);
563 } else {
564 header.extend(value.chars().filter(|&ch| is_pvalue_safe(ch)));
565 }
566}
567
568#[inline]
569fn push_quoted_pvalue(header: &mut String, value: &str) {
570 if !value.is_empty() && is_pvalue_clean(value) {
571 header.push_str(value);
572 } else {
573 header.push('"');
574 push_qcontent(header, value);
575 header.push('"');
576 }
577}
578
579#[inline]
580fn push_qcontent(header: &mut String, value: &str) {
581 let mut start = 0;
582 for (pos, ch) in value.char_indices() {
583 match ch {
584 '"' | '\\' => {
585 header.push_str(value.get(start..pos).unwrap_or_default());
586 header.push('\\');
587 header.push(ch);
588 start = pos + 1;
589 }
590 '\0'..='\u{1f}' | '\u{7f}'..='\u{9f}' => {
591 header.push_str(value.get(start..pos).unwrap_or_default());
592 start = pos + ch.len_utf8();
593 }
594 _ => {}
595 }
596 }
597 header.push_str(value.get(start..).unwrap_or_default());
598}
599
600#[cfg(test)]
601mod test {
602 #[cfg(feature = "arc")]
603 use crate::{ArcOutput, arc::ArcError};
604 use crate::{
605 AuthenticationResults, DkimOutput, DkimResult, DmarcOutput, DmarcResult, DnsError, Error,
606 IprevOutput, IprevResult, ReceivedSpf, SpfOutput, SpfResult,
607 common::crypto::CryptoError,
608 common::parse::TxtRecordParser,
609 dkim::Signature,
610 dmarc::{Dmarc, Policy},
611 };
612 use std::sync::Arc;
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 "dmarc=fail (policy not aligned) header.from=example.net policy.dmarc=reject",
790 DmarcOutput {
791 dkim_result: DmarcResult::None,
792 spf_result: DmarcResult::None,
793 domain: "example.net".to_string(),
794 policy: Policy::Reject,
795 record: Some(Arc::new(Dmarc::parse(b"v=DMARC1; p=reject").unwrap())),
796 },
797 ),
798 (
799 "dmarc=none header.from=example.net policy.dmarc=none",
800 DmarcOutput {
801 dkim_result: DmarcResult::None,
802 spf_result: DmarcResult::None,
803 domain: "example.net".to_string(),
804 policy: Policy::None,
805 record: None,
806 },
807 ),
808 ] {
809 auth_results = auth_results.with_dmarc_result(&dmarc);
810 assert_eq!(
811 auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
812 expected_auth_results
813 );
814 }
815
816 #[cfg(feature = "arc")]
817 for (expected_auth_results, arc, remote_ip) in [
818 (
819 "arc=pass smtp.remote-ip=192.127.9.2",
820 DkimResult::Pass,
821 "192.127.9.2".parse().unwrap(),
822 ),
823 (
824 "arc=neutral (body hash did not verify) smtp.remote-ip=\"1:2:3::a\"",
825 DkimResult::Neutral(Error::Arc(ArcError::FailedBodyHashMatch)),
826 "1:2:3::a".parse().unwrap(),
827 ),
828 ] {
829 auth_results = auth_results.with_arc_result(
830 &ArcOutput {
831 result: arc,
832 set: vec![],
833 },
834 remote_ip,
835 );
836 assert_eq!(
837 auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
838 expected_auth_results
839 );
840 }
841
842 for (expected_auth_results, iprev, remote_ip) in [
843 (
844 "iprev=pass policy.iprev=192.127.9.2",
845 IprevOutput {
846 result: IprevResult::Pass,
847 ptr: None,
848 },
849 "192.127.9.2".parse().unwrap(),
850 ),
851 (
852 "iprev=fail (policy not aligned) policy.iprev=\"1:2:3::a\"",
853 IprevOutput {
854 result: IprevResult::Fail(Error::NotAligned),
855 ptr: None,
856 },
857 "1:2:3::a".parse().unwrap(),
858 ),
859 ] {
860 auth_results = auth_results.with_iprev_result(&iprev, remote_ip);
861 assert_eq!(
862 auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
863 expected_auth_results
864 );
865 }
866 }
867
868 #[test]
869 fn dkim2_authentication_results() {
870 use crate::{
871 Dkim2Result,
872 dkim2::{ChainLink, Dkim2Error, Dkim2Output, Signature as Dkim2Signature},
873 };
874
875 let originator = Dkim2Signature {
876 i: 1,
877 d: "example.org".into(),
878 ..Default::default()
879 };
880 let relay = Dkim2Signature {
881 i: 2,
882 d: "relay.example.com".into(),
883 ..Default::default()
884 };
885
886 let pass = Dkim2Output {
887 result: Dkim2Result::Pass,
888 chain: vec![
889 ChainLink {
890 signature: &originator,
891 instance: None,
892 result: Dkim2Result::Pass,
893 custody_ok: true,
894 },
895 ChainLink {
896 signature: &relay,
897 instance: None,
898 result: Dkim2Result::Pass,
899 custody_ok: true,
900 },
901 ],
902 };
903
904 let fail = Dkim2Output {
905 result: Dkim2Result::Fail(Error::Dkim2(Dkim2Error::BodyHashMismatch(2))),
906 chain: vec![
907 ChainLink {
908 signature: &originator,
909 instance: None,
910 result: Dkim2Result::Pass,
911 custody_ok: true,
912 },
913 ChainLink {
914 signature: &relay,
915 instance: None,
916 result: Dkim2Result::Fail(Error::Dkim2(Dkim2Error::BodyHashMismatch(2))),
917 custody_ok: true,
918 },
919 ],
920 };
921
922 let permerror: Dkim2Output =
923 Dkim2Result::PermError(Error::Dkim2(Dkim2Error::SignatureMissing(1))).into();
924
925 let none: Dkim2Output = Dkim2Result::None.into();
926
927 for (expected, output) in [
928 ("dkim2=pass header.d=example.org header.i=1", &pass),
929 (
930 concat!(
931 "dkim2=fail (Message-Instance m=2 body hash mismatch) ",
932 "header.d=relay.example.com header.i=2"
933 ),
934 &fail,
935 ),
936 ("dkim2=permerror (DKIM2-Signature i=1 missing)", &permerror),
937 ("dkim2=none", &none),
938 ] {
939 let auth_results = AuthenticationResults::new("mydomain.org").with_dkim2_result(output);
940 assert_eq!(
941 auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
942 expected
943 );
944 }
945 }
946
947 #[test]
948 fn dkim_result_header_injection() {
949 let signature = Signature {
950 i: "u@evil.test\r\nReply-To: attacker@evil.test\r\nX-Injected: yes".into(),
951 d: "evil.test\r\nX-Injected-D: yes".into(),
952 s: "sel\r\nX-Injected-S: yes".into(),
953 b: b"123456".to_vec(),
954 ..Default::default()
955 };
956 let output = DkimOutput {
957 result: DkimResult::Fail(Error::Crypto(CryptoError::FailedVerification)),
958 signature: Some(&signature),
959 report: None,
960 is_atps: false,
961 };
962 let auth_results = AuthenticationResults::new("mx.example.org")
963 .with_dkim_result(&output, "from@example.org");
964
965 assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
966 let value = auth_results.auth_results.split_once("header.i=").unwrap().1;
967 assert!(!value.contains('\r') && !value.contains('\n'));
968 assert!(value.starts_with("\"u@evil.test"));
969 assert!(value.contains("Reply-To: attacker@evil.test"));
970 }
971
972 #[test]
973 fn dkim_result_header_i_quoted_local_part() {
974 let signature = Signature {
975 i: "a;b=c (note)\"x@example.org".into(),
976 d: "example.org".into(),
977 s: "sel".into(),
978 ..Default::default()
979 };
980 let output = DkimOutput {
981 result: DkimResult::Pass,
982 signature: Some(&signature),
983 report: None,
984 is_atps: false,
985 };
986 let auth_results = AuthenticationResults::new("mx.example.org")
987 .with_dkim_result(&output, "from@example.org");
988 let value = auth_results.auth_results.split_once("header.i=").unwrap().1;
989
990 assert!(value.starts_with("\"a;b=c (note)\\\"x@example.org\""));
991 assert_eq!(value.matches('"').count(), 3);
992 }
993
994 #[test]
995 fn dkim_result_header_d_injection() {
996 let signature = Signature {
997 d: "evil.test\r\nX-Injected: yes".into(),
998 s: "sel\"; smtp.bogus=1".into(),
999 ..Default::default()
1000 };
1001 let output = DkimOutput {
1002 result: DkimResult::Fail(Error::Crypto(CryptoError::FailedVerification)),
1003 signature: Some(&signature),
1004 report: None,
1005 is_atps: false,
1006 };
1007 let auth_results = AuthenticationResults::new("mx.example.org")
1008 .with_dkim_result(&output, "from@example.org");
1009
1010 assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
1011 let value = auth_results.auth_results.split_once("header.d=").unwrap().1;
1012 assert!(!value.contains('\r') && !value.contains('\n'));
1013 assert!(!value.contains('"') && !value.contains(';'));
1014 }
1015
1016 #[test]
1017 fn spf_result_header_injection() {
1018 let spf = SpfOutput {
1019 result: SpfResult::Pass,
1020 domain: String::new(),
1021 report: None,
1022 explanation: None,
1023 };
1024 let auth_results = AuthenticationResults::new("mx.example.org").with_spf_mailfrom_result(
1025 &spf,
1026 "192.168.1.1".parse().unwrap(),
1027 "a@evil.test\r\nX-Injected: yes",
1028 "helo.test\r\nX-Injected-Helo: yes",
1029 );
1030 assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
1031
1032 let auth_results = AuthenticationResults::new("mx.example.org").with_spf_ehlo_result(
1033 &spf,
1034 "192.168.1.1".parse().unwrap(),
1035 "helo.test\r\nX-Injected: yes",
1036 );
1037 assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
1038 }
1039
1040 #[test]
1041 fn dmarc_result_header_injection() {
1042 let auth_results =
1043 AuthenticationResults::new("mx.example.org").with_dmarc_result(&DmarcOutput {
1044 spf_result: DmarcResult::Pass,
1045 dkim_result: DmarcResult::None,
1046 domain: "evil.test\r\nX-Injected: yes".to_string(),
1047 policy: Policy::None,
1048 record: None,
1049 });
1050 assert_eq!(auth_results.auth_results.matches("\r\n").count(), 1);
1051 let value = auth_results
1052 .auth_results
1053 .split_once("header.from=")
1054 .unwrap()
1055 .1;
1056 assert!(!value.contains('\r') && !value.contains('\n'));
1057 }
1058
1059 #[test]
1060 fn received_spf_header_injection() {
1061 let spf = SpfOutput {
1062 result: SpfResult::Pass,
1063 domain: String::new(),
1064 report: None,
1065 explanation: None,
1066 };
1067 let received_spf = ReceivedSpf::new(
1068 &spf,
1069 "192.168.1.1".parse().unwrap(),
1070 "helo.test\r\nX-Injected-Helo: yes",
1071 "a@evil.test\r\nX-Injected: yes\r\nReply-To: attacker@evil.test",
1072 "mx.example.org",
1073 );
1074 assert_eq!(received_spf.received_spf.matches("\r\n").count(), 1);
1075 assert!(
1076 !received_spf.received_spf.contains('"')
1077 || received_spf.received_spf.matches('"').count() == 2
1078 );
1079 }
1080}