Skip to main content

max / makenotwork

9.3 KB · 272 lines History Blame Raw
1 //! Inbound sender authentication.
2 //!
3 //! Postmark's inbound webhook proves only that *Postmark relayed the message*,
4 //! the bearer token says nothing about who actually wrote it. The `From` address
5 //! is attacker-controlled: anyone can `git send-email` (or reply) with a forged
6 //! `From:` matching a verified MNW user and, unguarded, act as that user (file
7 //! issues, submit patches, post replies). See ultra-fuzz Run 13 Payments/Security
8 //! HIGH, inbound sender spoofing.
9 //!
10 //! Postmark forwards the original message headers, into which the receiving MX
11 //! stamps SPF/DKIM verdicts (`Authentication-Results`, `Received-SPF`). Before we
12 //! attribute an inbound message to the account owning its `From` address, we
13 //! require that SPF *or* DKIM **passed and is aligned** with the `From` domain.
14 //! Everything here is a pure function of `(from, headers)` so it is unit-tested
15 //! without a live mail path.
16
17 use super::PostmarkHeader;
18
19 /// Whether an inbound message's `From` address is authenticated.
20 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
21 pub(super) enum SenderAuth {
22 /// SPF or aligned DKIM passed for the `From` domain, trust the identity.
23 Aligned,
24 /// A verdict was present but neither SPF nor DKIM aligned/passed.
25 Unaligned,
26 /// No SPF/DKIM verdict was present in the forwarded headers.
27 Missing,
28 }
29
30 /// Decide whether to trust the inbound `From` as sender identity.
31 ///
32 /// Returns `true` to proceed. When `enforce` is false (observe-only rollout) an
33 /// unaligned/missing verdict is logged but allowed; when `enforce` is true it is
34 /// logged and rejected.
35 pub(super) fn inbound_sender_trusted(
36 enforce: bool,
37 from_email: &str,
38 headers: &[PostmarkHeader],
39 ) -> bool {
40 let verdict = classify_sender(from_email, headers);
41 match verdict {
42 SenderAuth::Aligned => true,
43 _ if !enforce => {
44 tracing::warn!(
45 from = %from_email, ?verdict,
46 "inbound: sender not SPF/DKIM-aligned; allowing (POSTMARK_ENFORCE_SENDER_AUTH=false, observe-only)"
47 );
48 true
49 }
50 _ => {
51 tracing::warn!(
52 from = %from_email, ?verdict,
53 "inbound: rejecting unauthenticated sender — SPF/DKIM not aligned with From domain (possible spoof)"
54 );
55 false
56 }
57 }
58 }
59
60 /// Classify the `From` address against the SPF/DKIM verdicts in `headers`.
61 pub(super) fn classify_sender(from_email: &str, headers: &[PostmarkHeader]) -> SenderAuth {
62 let Some(from_domain) = domain_of(from_email) else {
63 return SenderAuth::Unaligned;
64 };
65
66 let mut saw_verdict = false;
67
68 for h in headers {
69 if h.name.eq_ignore_ascii_case("Authentication-Results") {
70 let lower = h.value.to_ascii_lowercase();
71 for chunk in lower.split(';') {
72 let chunk = chunk.trim();
73 if let Some(rest) = chunk.strip_prefix("spf=") {
74 saw_verdict = true;
75 if rest.starts_with("pass")
76 && let Some(mf) = tag_value(chunk, "smtp.mailfrom=")
77 && let Some(d) = domain_of(mf)
78 && domains_aligned(from_domain, d)
79 {
80 return SenderAuth::Aligned;
81 }
82 } else if let Some(rest) = chunk.strip_prefix("dkim=") {
83 saw_verdict = true;
84 if rest.starts_with("pass")
85 && let Some(d) = tag_value(chunk, "header.d=")
86 && domains_aligned(from_domain, d)
87 {
88 return SenderAuth::Aligned;
89 }
90 }
91 }
92 } else if h.name.eq_ignore_ascii_case("Received-SPF") {
93 saw_verdict = true;
94 let lower = h.value.to_ascii_lowercase();
95 let result = lower.split_whitespace().next().unwrap_or("");
96 if result == "pass"
97 && let Some(ef) = tag_value(&lower, "envelope-from=")
98 && let Some(d) = domain_of(ef)
99 && domains_aligned(from_domain, d)
100 {
101 return SenderAuth::Aligned;
102 }
103 }
104 }
105
106 if saw_verdict {
107 SenderAuth::Unaligned
108 } else {
109 SenderAuth::Missing
110 }
111 }
112
113 /// The lowercased domain of an email address (or bare domain), stripped of the
114 /// angle brackets / quotes SPF/DKIM tags often wrap values in.
115 fn domain_of(email: &str) -> Option<&str> {
116 let d = match email.rsplit_once('@') {
117 Some((_, d)) => d,
118 None => email,
119 };
120 let d = d
121 .trim()
122 .trim_matches(|c| matches!(c, '>' | '<' | '"' | ';' | ',' | '(' | ')'));
123 if d.is_empty() || !d.contains('.') {
124 None
125 } else {
126 Some(d)
127 }
128 }
129
130 /// Relaxed domain alignment: `auth_domain` equals `from_domain` or is its
131 /// organizational parent (so DKIM `d=example.com` covers `mail.example.com`).
132 fn domains_aligned(from_domain: &str, auth_domain: &str) -> bool {
133 let from = from_domain.trim_end_matches('.').to_ascii_lowercase();
134 let auth = auth_domain.trim_end_matches('.').to_ascii_lowercase();
135 !auth.is_empty() && (from == auth || from.ends_with(&format!(".{auth}")))
136 }
137
138 /// Pull the value of `key` (a `key=` tag) from `s`, up to the next whitespace or
139 /// `;`. `s` is expected already-lowercased by the caller.
140 fn tag_value<'a>(s: &'a str, key: &str) -> Option<&'a str> {
141 let start = s.find(key)? + key.len();
142 let rest = &s[start..];
143 let end = rest
144 .find(|c: char| c.is_whitespace() || c == ';')
145 .unwrap_or(rest.len());
146 let val = rest[..end].trim();
147 (!val.is_empty()).then_some(val)
148 }
149
150 #[cfg(test)]
151 mod tests {
152 use super::*;
153
154 fn hdr(name: &str, value: &str) -> PostmarkHeader {
155 PostmarkHeader {
156 name: name.to_string(),
157 value: value.to_string(),
158 }
159 }
160
161 #[test]
162 fn spf_pass_aligned_is_authenticated() {
163 let headers = vec![hdr(
164 "Authentication-Results",
165 "mx.postmark.com; spf=pass smtp.mailfrom=alice@example.com; dkim=none; dmarc=pass",
166 )];
167 assert_eq!(
168 classify_sender("alice@example.com", &headers),
169 SenderAuth::Aligned
170 );
171 }
172
173 #[test]
174 fn dkim_pass_aligned_is_authenticated() {
175 let headers = vec![hdr(
176 "Authentication-Results",
177 "mx.postmark.com; spf=softfail; dkim=pass header.d=example.com header.i=@example.com",
178 )];
179 assert_eq!(
180 classify_sender("alice@example.com", &headers),
181 SenderAuth::Aligned
182 );
183 }
184
185 #[test]
186 fn dkim_on_org_domain_covers_subdomain_from() {
187 let headers = vec![hdr(
188 "Authentication-Results",
189 "mx; dkim=pass header.d=example.com",
190 )];
191 assert_eq!(
192 classify_sender("bob@mail.example.com", &headers),
193 SenderAuth::Aligned
194 );
195 }
196
197 #[test]
198 fn received_spf_pass_is_authenticated() {
199 let headers = vec![hdr(
200 "Received-SPF",
201 "Pass (postmark: domain of alice@example.com designates 1.2.3.4) envelope-from=alice@example.com; client-ip=1.2.3.4",
202 )];
203 assert_eq!(
204 classify_sender("alice@example.com", &headers),
205 SenderAuth::Aligned
206 );
207 }
208
209 #[test]
210 fn spoofed_from_with_attacker_spf_domain_is_unaligned() {
211 // The attacker's own domain passes SPF, but `From` claims the victim's,
212 // the exact spoof this guard closes.
213 let headers = vec![hdr(
214 "Authentication-Results",
215 "mx.postmark.com; spf=pass smtp.mailfrom=mallory@evil.test; dkim=pass header.d=evil.test",
216 )];
217 assert_eq!(
218 classify_sender("victim@example.com", &headers),
219 SenderAuth::Unaligned
220 );
221 }
222
223 #[test]
224 fn spf_and_dkim_fail_is_unaligned() {
225 let headers = vec![hdr(
226 "Authentication-Results",
227 "mx; spf=fail smtp.mailfrom=alice@example.com; dkim=fail header.d=example.com",
228 )];
229 assert_eq!(
230 classify_sender("alice@example.com", &headers),
231 SenderAuth::Unaligned
232 );
233 }
234
235 #[test]
236 fn no_auth_headers_is_missing() {
237 let headers = vec![hdr("Subject", "hi"), hdr("In-Reply-To", "<x@y>")];
238 assert_eq!(
239 classify_sender("alice@example.com", &headers),
240 SenderAuth::Missing
241 );
242 }
243
244 #[test]
245 fn enforce_rejects_unaligned_and_missing() {
246 let unaligned = vec![hdr(
247 "Authentication-Results",
248 "mx; spf=fail smtp.mailfrom=x@evil.test",
249 )];
250 let missing: Vec<PostmarkHeader> = vec![];
251 assert!(!inbound_sender_trusted(
252 true,
253 "alice@example.com",
254 &unaligned
255 ));
256 assert!(!inbound_sender_trusted(true, "alice@example.com", &missing));
257 }
258
259 #[test]
260 fn observe_only_allows_but_still_trusts_aligned() {
261 let aligned = vec![hdr(
262 "Authentication-Results",
263 "mx; spf=pass smtp.mailfrom=alice@example.com",
264 )];
265 let missing: Vec<PostmarkHeader> = vec![];
266 // observe-only: everything allowed
267 assert!(inbound_sender_trusted(false, "alice@example.com", &missing));
268 // enforce: aligned still allowed
269 assert!(inbound_sender_trusted(true, "alice@example.com", &aligned));
270 }
271 }
272