//! Inbound sender authentication. //! //! Postmark's inbound webhook proves only that *Postmark relayed the message*, //! the bearer token says nothing about who actually wrote it. The `From` address //! is attacker-controlled: anyone can `git send-email` (or reply) with a forged //! `From:` matching a verified MNW user and, unguarded, act as that user (file //! issues, submit patches, post replies). See ultra-fuzz Run 13 Payments/Security //! HIGH, inbound sender spoofing. //! //! Postmark forwards the original message headers, into which the receiving MX //! stamps SPF/DKIM verdicts (`Authentication-Results`, `Received-SPF`). Before we //! attribute an inbound message to the account owning its `From` address, we //! require that SPF *or* DKIM **passed and is aligned** with the `From` domain. //! Everything here is a pure function of `(from, headers)` so it is unit-tested //! without a live mail path. use super::PostmarkHeader; /// Whether an inbound message's `From` address is authenticated. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum SenderAuth { /// SPF or aligned DKIM passed for the `From` domain, trust the identity. Aligned, /// A verdict was present but neither SPF nor DKIM aligned/passed. Unaligned, /// No SPF/DKIM verdict was present in the forwarded headers. Missing, } /// Decide whether to trust the inbound `From` as sender identity. /// /// Returns `true` to proceed. When `enforce` is false (observe-only rollout) an /// unaligned/missing verdict is logged but allowed; when `enforce` is true it is /// logged and rejected. pub(super) fn inbound_sender_trusted( enforce: bool, from_email: &str, headers: &[PostmarkHeader], ) -> bool { let verdict = classify_sender(from_email, headers); match verdict { SenderAuth::Aligned => true, _ if !enforce => { tracing::warn!( from = %from_email, ?verdict, "inbound: sender not SPF/DKIM-aligned; allowing (POSTMARK_ENFORCE_SENDER_AUTH=false, observe-only)" ); true } _ => { tracing::warn!( from = %from_email, ?verdict, "inbound: rejecting unauthenticated sender — SPF/DKIM not aligned with From domain (possible spoof)" ); false } } } /// Classify the `From` address against the SPF/DKIM verdicts in `headers`. pub(super) fn classify_sender(from_email: &str, headers: &[PostmarkHeader]) -> SenderAuth { let Some(from_domain) = domain_of(from_email) else { return SenderAuth::Unaligned; }; let mut saw_verdict = false; for h in headers { if h.name.eq_ignore_ascii_case("Authentication-Results") { let lower = h.value.to_ascii_lowercase(); for chunk in lower.split(';') { let chunk = chunk.trim(); if let Some(rest) = chunk.strip_prefix("spf=") { saw_verdict = true; if rest.starts_with("pass") && let Some(mf) = tag_value(chunk, "smtp.mailfrom=") && let Some(d) = domain_of(mf) && domains_aligned(from_domain, d) { return SenderAuth::Aligned; } } else if let Some(rest) = chunk.strip_prefix("dkim=") { saw_verdict = true; if rest.starts_with("pass") && let Some(d) = tag_value(chunk, "header.d=") && domains_aligned(from_domain, d) { return SenderAuth::Aligned; } } } } else if h.name.eq_ignore_ascii_case("Received-SPF") { saw_verdict = true; let lower = h.value.to_ascii_lowercase(); let result = lower.split_whitespace().next().unwrap_or(""); if result == "pass" && let Some(ef) = tag_value(&lower, "envelope-from=") && let Some(d) = domain_of(ef) && domains_aligned(from_domain, d) { return SenderAuth::Aligned; } } } if saw_verdict { SenderAuth::Unaligned } else { SenderAuth::Missing } } /// The lowercased domain of an email address (or bare domain), stripped of the /// angle brackets / quotes SPF/DKIM tags often wrap values in. fn domain_of(email: &str) -> Option<&str> { let d = match email.rsplit_once('@') { Some((_, d)) => d, None => email, }; let d = d .trim() .trim_matches(|c| matches!(c, '>' | '<' | '"' | ';' | ',' | '(' | ')')); if d.is_empty() || !d.contains('.') { None } else { Some(d) } } /// Relaxed domain alignment: `auth_domain` equals `from_domain` or is its /// organizational parent (so DKIM `d=example.com` covers `mail.example.com`). fn domains_aligned(from_domain: &str, auth_domain: &str) -> bool { let from = from_domain.trim_end_matches('.').to_ascii_lowercase(); let auth = auth_domain.trim_end_matches('.').to_ascii_lowercase(); !auth.is_empty() && (from == auth || from.ends_with(&format!(".{auth}"))) } /// Pull the value of `key` (a `key=` tag) from `s`, up to the next whitespace or /// `;`. `s` is expected already-lowercased by the caller. fn tag_value<'a>(s: &'a str, key: &str) -> Option<&'a str> { let start = s.find(key)? + key.len(); let rest = &s[start..]; let end = rest .find(|c: char| c.is_whitespace() || c == ';') .unwrap_or(rest.len()); let val = rest[..end].trim(); (!val.is_empty()).then_some(val) } #[cfg(test)] mod tests { use super::*; fn hdr(name: &str, value: &str) -> PostmarkHeader { PostmarkHeader { name: name.to_string(), value: value.to_string(), } } #[test] fn spf_pass_aligned_is_authenticated() { let headers = vec![hdr( "Authentication-Results", "mx.postmark.com; spf=pass smtp.mailfrom=alice@example.com; dkim=none; dmarc=pass", )]; assert_eq!( classify_sender("alice@example.com", &headers), SenderAuth::Aligned ); } #[test] fn dkim_pass_aligned_is_authenticated() { let headers = vec![hdr( "Authentication-Results", "mx.postmark.com; spf=softfail; dkim=pass header.d=example.com header.i=@example.com", )]; assert_eq!( classify_sender("alice@example.com", &headers), SenderAuth::Aligned ); } #[test] fn dkim_on_org_domain_covers_subdomain_from() { let headers = vec![hdr( "Authentication-Results", "mx; dkim=pass header.d=example.com", )]; assert_eq!( classify_sender("bob@mail.example.com", &headers), SenderAuth::Aligned ); } #[test] fn received_spf_pass_is_authenticated() { let headers = vec![hdr( "Received-SPF", "Pass (postmark: domain of alice@example.com designates 1.2.3.4) envelope-from=alice@example.com; client-ip=1.2.3.4", )]; assert_eq!( classify_sender("alice@example.com", &headers), SenderAuth::Aligned ); } #[test] fn spoofed_from_with_attacker_spf_domain_is_unaligned() { // The attacker's own domain passes SPF, but `From` claims the victim's, // the exact spoof this guard closes. let headers = vec![hdr( "Authentication-Results", "mx.postmark.com; spf=pass smtp.mailfrom=mallory@evil.test; dkim=pass header.d=evil.test", )]; assert_eq!( classify_sender("victim@example.com", &headers), SenderAuth::Unaligned ); } #[test] fn spf_and_dkim_fail_is_unaligned() { let headers = vec![hdr( "Authentication-Results", "mx; spf=fail smtp.mailfrom=alice@example.com; dkim=fail header.d=example.com", )]; assert_eq!( classify_sender("alice@example.com", &headers), SenderAuth::Unaligned ); } #[test] fn no_auth_headers_is_missing() { let headers = vec![hdr("Subject", "hi"), hdr("In-Reply-To", "")]; assert_eq!( classify_sender("alice@example.com", &headers), SenderAuth::Missing ); } #[test] fn enforce_rejects_unaligned_and_missing() { let unaligned = vec![hdr( "Authentication-Results", "mx; spf=fail smtp.mailfrom=x@evil.test", )]; let missing: Vec = vec![]; assert!(!inbound_sender_trusted( true, "alice@example.com", &unaligned )); assert!(!inbound_sender_trusted(true, "alice@example.com", &missing)); } #[test] fn observe_only_allows_but_still_trusts_aligned() { let aligned = vec![hdr( "Authentication-Results", "mx; spf=pass smtp.mailfrom=alice@example.com", )]; let missing: Vec = vec![]; // observe-only: everything allowed assert!(inbound_sender_trusted(false, "alice@example.com", &missing)); // enforce: aligned still allowed assert!(inbound_sender_trusted(true, "alice@example.com", &aligned)); } }