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