Skip to main content

max / makenotwork

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