max / makenotwork
10 files changed,
+438 insertions,
-3 deletions
| @@ -883,6 +883,7 @@ mod tests { | |||
| 883 | 883 | cdn_base_url: None, | |
| 884 | 884 | user_pages_host: std::sync::Arc::from("u.localhost"), | |
| 885 | 885 | postmark_inbound_webhook_token: None, | |
| 886 | + | postmark_enforce_sender_auth: true, | |
| 886 | 887 | internal_shared_secret: None, | |
| 887 | 888 | cli_service_token: None, | |
| 888 | 889 | wam_url: None, | |
| @@ -953,6 +954,7 @@ mod tests { | |||
| 953 | 954 | cdn_base_url: None, | |
| 954 | 955 | user_pages_host: std::sync::Arc::from("u.localhost"), | |
| 955 | 956 | postmark_inbound_webhook_token: None, | |
| 957 | + | postmark_enforce_sender_auth: true, | |
| 956 | 958 | internal_shared_secret: None, | |
| 957 | 959 | cli_service_token: None, | |
| 958 | 960 | wam_url: None, |
| @@ -84,6 +84,13 @@ pub struct Config { | |||
| 84 | 84 | pub user_pages_host: Arc<str>, | |
| 85 | 85 | /// Bearer token for authenticating Postmark inbound email webhook (optional). | |
| 86 | 86 | pub postmark_inbound_webhook_token: Option<String>, | |
| 87 | + | /// Enforce SPF/DKIM alignment on inbound email before trusting the `From` | |
| 88 | + | /// address as an MNW user's identity (issues, patches, replies). Defaults to | |
| 89 | + | /// `true` (fail closed): a message whose `From` domain isn't SPF/DKIM-aligned | |
| 90 | + | /// is not attributed to the account that owns that address, closing the | |
| 91 | + | /// inbound sender-spoofing hole. Set `POSTMARK_ENFORCE_SENDER_AUTH=false` | |
| 92 | + | /// only to observe verdicts during rollout (logs but does not reject). | |
| 93 | + | pub postmark_enforce_sender_auth: bool, | |
| 87 | 94 | /// Shared secret for HMAC-signed internal API requests to MT. | |
| 88 | 95 | /// Must match `INTERNAL_SHARED_SECRET` on the MT instance. | |
| 89 | 96 | pub internal_shared_secret: Option<String>, | |
| @@ -356,6 +363,14 @@ impl Config { | |||
| 356 | 363 | // Postmark inbound email webhook token - optional, inbound endpoint returns 401 if unset | |
| 357 | 364 | let postmark_inbound_webhook_token = std::env::var("POSTMARK_INBOUND_WEBHOOK_TOKEN").ok(); | |
| 358 | 365 | ||
| 366 | + | // Enforce inbound SPF/DKIM sender-auth by default; only an explicit | |
| 367 | + | // `false` disables it (observe-only during rollout). Fail closed so a | |
| 368 | + | // missing/typo'd value can't silently reopen the spoofing hole. | |
| 369 | + | let postmark_enforce_sender_auth = std::env::var("POSTMARK_ENFORCE_SENDER_AUTH") | |
| 370 | + | .ok() | |
| 371 | + | .map(|v| !(v == "false" || v == "0")) | |
| 372 | + | .unwrap_or(true); | |
| 373 | + | ||
| 359 | 374 | // Internal shared secret for MT communication. Bearer-token-equivalent, so | |
| 360 | 375 | // enforce the same >=32-char floor as the signing secrets — a short value | |
| 361 | 376 | // is offline-brute-forceable. Fail closed rather than boot on a weak secret. | |
| @@ -421,6 +436,7 @@ impl Config { | |||
| 421 | 436 | cdn_base_url, | |
| 422 | 437 | user_pages_host: Arc::from(user_pages_host), | |
| 423 | 438 | postmark_inbound_webhook_token, | |
| 439 | + | postmark_enforce_sender_auth, | |
| 424 | 440 | internal_shared_secret, | |
| 425 | 441 | cli_service_token, | |
| 426 | 442 | wam_url, | |
| @@ -798,6 +814,7 @@ mod tests { | |||
| 798 | 814 | cdn_base_url: None, | |
| 799 | 815 | user_pages_host: Arc::from("u.localhost"), | |
| 800 | 816 | postmark_inbound_webhook_token: None, | |
| 817 | + | postmark_enforce_sender_auth: true, | |
| 801 | 818 | internal_shared_secret: None, | |
| 802 | 819 | cli_service_token: None, | |
| 803 | 820 | wam_url: None, |
| @@ -707,6 +707,53 @@ impl ValidatedPromo { | |||
| 707 | 707 | pub fn id(&self) -> PromoCodeId { | |
| 708 | 708 | self.code.id | |
| 709 | 709 | } | |
| 710 | + | ||
| 711 | + | /// The maximum total platform credit MNW will transfer for a single | |
| 712 | + | /// redemption of this code — the credit's face value — or `None` when the | |
| 713 | + | /// code carries no spend-once balance. | |
| 714 | + | /// | |
| 715 | + | /// A platform-wide *fixed* credit (the $5 Fan+ renewal credit) is a monetary | |
| 716 | + | /// BALANCE, spent at most once across a multi-item cart, not a per-line | |
| 717 | + | /// coupon. `Some(budget)` lets the cart cap the cumulative discount+credit at | |
| 718 | + | /// that value so one single-use credit can't discount the buyer and reimburse | |
| 719 | + | /// the seller N times over a cart (ultra-fuzz Run 13 Payments SERIOUS). | |
| 720 | + | /// `None` means there is no balance to over-spend: a seller-funded code (its | |
| 721 | + | /// credit is always `0`) or a platform-wide *percentage* code (an intentional | |
| 722 | + | /// platform-funded sale that legitimately applies to every line). | |
| 723 | + | pub fn platform_credit_budget_cents(&self) -> Option<i64> { | |
| 724 | + | if !self.is_platform_wide { | |
| 725 | + | return None; | |
| 726 | + | } | |
| 727 | + | match (self.code.code_purpose, self.code.discount_type, self.code.discount_value) { | |
| 728 | + | (CodePurpose::Discount, Some(DiscountType::Fixed), Some(value)) => { | |
| 729 | + | Some(i64::from(value.max(0))) | |
| 730 | + | } | |
| 731 | + | _ => None, | |
| 732 | + | } | |
| 733 | + | } | |
| 734 | + | } | |
| 735 | + | ||
| 736 | + | /// Spend a platform credit BALANCE across one cart line, capping it to the | |
| 737 | + | /// remaining budget. Returns the line's `(final_price_cents, platform_credit_cents)` | |
| 738 | + | /// and decrements `*budget` by the credit actually granted; the uncovered part of | |
| 739 | + | /// the discount reverts to the buyer's bill so a single-use credit is spent at | |
| 740 | + | /// most once across the whole cart (ultra-fuzz Run 13 Payments SERIOUS). | |
| 741 | + | /// `*budget == None` disables the cap (per-line: seller-funded or percentage). | |
| 742 | + | pub fn cap_line_to_credit_budget( | |
| 743 | + | applied: AppliedDiscount, | |
| 744 | + | budget: &mut Option<i64>, | |
| 745 | + | ) -> (i32, i64) { | |
| 746 | + | let mut final_price = applied.price_cents; | |
| 747 | + | let mut credit = i64::from(applied.funding.platform_credit_cents()); | |
| 748 | + | if let Some(remaining) = budget.as_mut() { | |
| 749 | + | let granted = credit.min(*remaining); | |
| 750 | + | // The buyer pays the discount the balance can no longer cover; price | |
| 751 | + | // rises back toward full, keeping `final_price == base - granted`. | |
| 752 | + | final_price += (credit - granted) as i32; | |
| 753 | + | credit = granted; | |
| 754 | + | *remaining -= granted; | |
| 755 | + | } | |
| 756 | + | (final_price, credit) | |
| 710 | 757 | } | |
| 711 | 758 | ||
| 712 | 759 | /// Look up and code-level-validate a checkout promo. Tries the seller's code | |
| @@ -1167,4 +1214,96 @@ mod tests { | |||
| 1167 | 1214 | // handler's once-per-checkout concern. | |
| 1168 | 1215 | assert_eq!(promo.code.use_count, 0); | |
| 1169 | 1216 | } | |
| 1217 | + | ||
| 1218 | + | // -- Platform credit is a spend-once balance (ultra-fuzz Run 13 Payments) -- | |
| 1219 | + | ||
| 1220 | + | /// Build a platform-wide fixed credit (the $5 Fan+ renewal credit shape). | |
| 1221 | + | fn platform_fixed_credit(cents: i32) -> ValidatedPromo { | |
| 1222 | + | ValidatedPromo { | |
| 1223 | + | code: DbPromoCode { | |
| 1224 | + | id: PromoCodeId::new(), | |
| 1225 | + | creator_id: UserId::new(), | |
| 1226 | + | code: "FANPLUS".to_string(), | |
| 1227 | + | code_purpose: CodePurpose::Discount, | |
| 1228 | + | discount_type: Some(DiscountType::Fixed), | |
| 1229 | + | discount_value: Some(cents), | |
| 1230 | + | min_price_cents: 0, | |
| 1231 | + | trial_days: None, | |
| 1232 | + | item_id: None, | |
| 1233 | + | project_id: None, | |
| 1234 | + | tier_id: None, | |
| 1235 | + | max_uses: None, | |
| 1236 | + | use_count: 0, | |
| 1237 | + | expires_at: None, | |
| 1238 | + | starts_at: None, | |
| 1239 | + | created_at: chrono::Utc::now(), | |
| 1240 | + | is_platform_wide: true, | |
| 1241 | + | }, | |
| 1242 | + | is_platform_wide: true, | |
| 1243 | + | } | |
| 1244 | + | } | |
| 1245 | + | ||
| 1246 | + | #[test] | |
| 1247 | + | fn platform_fixed_credit_budget_is_face_value() { | |
| 1248 | + | assert_eq!(platform_fixed_credit(500).platform_credit_budget_cents(), Some(500)); | |
| 1249 | + | } | |
| 1250 | + | ||
| 1251 | + | #[test] | |
| 1252 | + | fn seller_and_percentage_codes_have_no_credit_budget() { | |
| 1253 | + | // Seller-funded code: credit is always 0, no balance to cap. | |
| 1254 | + | assert_eq!(unscoped_discount_promo(None).platform_credit_budget_cents(), None); | |
| 1255 | + | // Platform-wide *percentage*: an intentional platform-funded sale that | |
| 1256 | + | // legitimately applies to every line — not a spend-once balance. | |
| 1257 | + | let mut pct = platform_fixed_credit(500); | |
| 1258 | + | pct.code.discount_type = Some(DiscountType::Percentage); | |
| 1259 | + | pct.code.discount_value = Some(20); | |
| 1260 | + | assert_eq!(pct.platform_credit_budget_cents(), None); | |
| 1261 | + | } | |
| 1262 | + | ||
| 1263 | + | #[test] | |
| 1264 | + | fn platform_fixed_credit_spent_once_across_cart() { | |
| 1265 | + | // The $5 (500¢) Fan+ credit across three $10 (1000¢) lines must discount | |
| 1266 | + | // the buyer and reimburse the seller a total of exactly 500¢ — once — not | |
| 1267 | + | // 500¢ per line (Run 13 SERIOUS: cart platform-credit multiplication). | |
| 1268 | + | let promo = platform_fixed_credit(500); | |
| 1269 | + | let mut budget = promo.platform_credit_budget_cents(); | |
| 1270 | + | assert_eq!(budget, Some(500)); | |
| 1271 | + | ||
| 1272 | + | let mut total_credit = 0i64; | |
| 1273 | + | let mut total_buyer_paid = 0i64; | |
| 1274 | + | for _ in 0..3 { | |
| 1275 | + | let PromoApplication::Apply(applied) = | |
| 1276 | + | apply_promo_to_item(&promo, ItemId::new(), ProjectId::new(), 1000).unwrap() | |
| 1277 | + | else { | |
| 1278 | + | panic!("expected Apply for an eligible platform-credit line"); | |
| 1279 | + | }; | |
| 1280 | + | let (final_price, credit) = cap_line_to_credit_budget(applied, &mut budget); | |
| 1281 | + | total_credit += credit; | |
| 1282 | + | total_buyer_paid += i64::from(final_price); | |
| 1283 | + | } | |
| 1284 | + | assert_eq!(total_credit, 500, "MNW reimburses the seller exactly the face value, once"); | |
| 1285 | + | assert_eq!(total_buyer_paid, 3000 - 500, "buyer gets the $5 credit exactly once"); | |
| 1286 | + | assert_eq!(budget, Some(0), "balance fully spent"); | |
| 1287 | + | } | |
| 1288 | + | ||
| 1289 | + | #[test] | |
| 1290 | + | fn platform_fixed_credit_carries_balance_across_cheap_lines() { | |
| 1291 | + | // A $5 credit on two $1 (100¢) items spends 100 then 100 (the balance | |
| 1292 | + | // carries instead of burning the whole $5 on the first line); 300¢ remain. | |
| 1293 | + | let promo = platform_fixed_credit(500); | |
| 1294 | + | let mut budget = promo.platform_credit_budget_cents(); | |
| 1295 | + | let mut total_credit = 0i64; | |
| 1296 | + | for _ in 0..2 { | |
| 1297 | + | let PromoApplication::Apply(applied) = | |
| 1298 | + | apply_promo_to_item(&promo, ItemId::new(), ProjectId::new(), 100).unwrap() | |
| 1299 | + | else { | |
| 1300 | + | panic!("expected Apply"); | |
| 1301 | + | }; | |
| 1302 | + | let (final_price, credit) = cap_line_to_credit_budget(applied, &mut budget); | |
| 1303 | + | assert_eq!(final_price, 0, "a $1 item is fully covered by the credit"); | |
| 1304 | + | total_credit += credit; | |
| 1305 | + | } | |
| 1306 | + | assert_eq!(total_credit, 200); | |
| 1307 | + | assert_eq!(budget, Some(300), "unspent balance carries to the rest of the cart"); | |
| 1308 | + | } | |
| 1170 | 1309 | } |
| @@ -0,0 +1,232 @@ | |||
| 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 { SenderAuth::Unaligned } else { SenderAuth::Missing } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /// The lowercased domain of an email address (or bare domain), stripped of the | |
| 110 | + | /// angle brackets / quotes SPF/DKIM tags often wrap values in. | |
| 111 | + | fn domain_of(email: &str) -> Option<&str> { | |
| 112 | + | let d = match email.rsplit_once('@') { | |
| 113 | + | Some((_, d)) => d, | |
| 114 | + | None => email, | |
| 115 | + | }; | |
| 116 | + | let d = d.trim().trim_matches(|c| matches!(c, '>' | '<' | '"' | ';' | ',' | '(' | ')')); | |
| 117 | + | if d.is_empty() || !d.contains('.') { | |
| 118 | + | None | |
| 119 | + | } else { | |
| 120 | + | Some(d) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /// Relaxed domain alignment: `auth_domain` equals `from_domain` or is its | |
| 125 | + | /// organizational parent (so DKIM `d=example.com` covers `mail.example.com`). | |
| 126 | + | fn domains_aligned(from_domain: &str, auth_domain: &str) -> bool { | |
| 127 | + | let from = from_domain.trim_end_matches('.').to_ascii_lowercase(); | |
| 128 | + | let auth = auth_domain.trim_end_matches('.').to_ascii_lowercase(); | |
| 129 | + | !auth.is_empty() && (from == auth || from.ends_with(&format!(".{auth}"))) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// Pull the value of `key` (a `key=` tag) from `s`, up to the next whitespace or | |
| 133 | + | /// `;`. `s` is expected already-lowercased by the caller. | |
| 134 | + | fn tag_value<'a>(s: &'a str, key: &str) -> Option<&'a str> { | |
| 135 | + | let start = s.find(key)? + key.len(); | |
| 136 | + | let rest = &s[start..]; | |
| 137 | + | let end = rest.find(|c: char| c.is_whitespace() || c == ';').unwrap_or(rest.len()); | |
| 138 | + | let val = rest[..end].trim(); | |
| 139 | + | (!val.is_empty()).then_some(val) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | #[cfg(test)] | |
| 143 | + | mod tests { | |
| 144 | + | use super::*; | |
| 145 | + | ||
| 146 | + | fn hdr(name: &str, value: &str) -> PostmarkHeader { | |
| 147 | + | PostmarkHeader { name: name.to_string(), value: value.to_string() } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | #[test] | |
| 151 | + | fn spf_pass_aligned_is_authenticated() { | |
| 152 | + | let headers = vec![hdr( | |
| 153 | + | "Authentication-Results", | |
| 154 | + | "mx.postmark.com; spf=pass smtp.mailfrom=alice@example.com; dkim=none; dmarc=pass", | |
| 155 | + | )]; | |
| 156 | + | assert_eq!(classify_sender("alice@example.com", &headers), SenderAuth::Aligned); | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | #[test] | |
| 160 | + | fn dkim_pass_aligned_is_authenticated() { | |
| 161 | + | let headers = vec![hdr( | |
| 162 | + | "Authentication-Results", | |
| 163 | + | "mx.postmark.com; spf=softfail; dkim=pass header.d=example.com header.i=@example.com", | |
| 164 | + | )]; | |
| 165 | + | assert_eq!(classify_sender("alice@example.com", &headers), SenderAuth::Aligned); | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | #[test] | |
| 169 | + | fn dkim_on_org_domain_covers_subdomain_from() { | |
| 170 | + | let headers = vec![hdr( | |
| 171 | + | "Authentication-Results", | |
| 172 | + | "mx; dkim=pass header.d=example.com", | |
| 173 | + | )]; | |
| 174 | + | assert_eq!(classify_sender("bob@mail.example.com", &headers), SenderAuth::Aligned); | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | #[test] | |
| 178 | + | fn received_spf_pass_is_authenticated() { | |
| 179 | + | let headers = vec![hdr( | |
| 180 | + | "Received-SPF", | |
| 181 | + | "Pass (postmark: domain of alice@example.com designates 1.2.3.4) envelope-from=alice@example.com; client-ip=1.2.3.4", | |
| 182 | + | )]; | |
| 183 | + | assert_eq!(classify_sender("alice@example.com", &headers), SenderAuth::Aligned); | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | #[test] | |
| 187 | + | fn spoofed_from_with_attacker_spf_domain_is_unaligned() { | |
| 188 | + | // The attacker's own domain passes SPF, but `From` claims the victim's — | |
| 189 | + | // the exact spoof this guard closes. | |
| 190 | + | let headers = vec![hdr( | |
| 191 | + | "Authentication-Results", | |
| 192 | + | "mx.postmark.com; spf=pass smtp.mailfrom=mallory@evil.test; dkim=pass header.d=evil.test", | |
| 193 | + | )]; | |
| 194 | + | assert_eq!( | |
| 195 | + | classify_sender("victim@example.com", &headers), | |
| 196 | + | SenderAuth::Unaligned | |
| 197 | + | ); | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | #[test] | |
| 201 | + | fn spf_and_dkim_fail_is_unaligned() { | |
| 202 | + | let headers = vec![hdr( | |
| 203 | + | "Authentication-Results", | |
| 204 | + | "mx; spf=fail smtp.mailfrom=alice@example.com; dkim=fail header.d=example.com", | |
| 205 | + | )]; | |
| 206 | + | assert_eq!(classify_sender("alice@example.com", &headers), SenderAuth::Unaligned); | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | #[test] | |
| 210 | + | fn no_auth_headers_is_missing() { | |
| 211 | + | let headers = vec![hdr("Subject", "hi"), hdr("In-Reply-To", "<x@y>")]; | |
| 212 | + | assert_eq!(classify_sender("alice@example.com", &headers), SenderAuth::Missing); | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | #[test] | |
| 216 | + | fn enforce_rejects_unaligned_and_missing() { | |
| 217 | + | let unaligned = vec![hdr("Authentication-Results", "mx; spf=fail smtp.mailfrom=x@evil.test")]; | |
| 218 | + | let missing: Vec<PostmarkHeader> = vec![]; | |
| 219 | + | assert!(!inbound_sender_trusted(true, "alice@example.com", &unaligned)); | |
| 220 | + | assert!(!inbound_sender_trusted(true, "alice@example.com", &missing)); | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | #[test] | |
| 224 | + | fn observe_only_allows_but_still_trusts_aligned() { | |
| 225 | + | let aligned = vec![hdr("Authentication-Results", "mx; spf=pass smtp.mailfrom=alice@example.com")]; | |
| 226 | + | let missing: Vec<PostmarkHeader> = vec![]; | |
| 227 | + | // observe-only: everything allowed | |
| 228 | + | assert!(inbound_sender_trusted(false, "alice@example.com", &missing)); | |
| 229 | + | // enforce: aligned still allowed | |
| 230 | + | assert!(inbound_sender_trusted(true, "alice@example.com", &aligned)); | |
| 231 | + | } | |
| 232 | + | } |
| @@ -61,6 +61,15 @@ async fn handle_new_issue( | |||
| 61 | 61 | return HandlerOutcome::Terminal(StatusCode::OK); | |
| 62 | 62 | } | |
| 63 | 63 | }; | |
| 64 | + | // Reject a spoofed `From` before attributing the issue to that account | |
| 65 | + | // (Run 13 sender-spoofing): require SPF/DKIM alignment with the From domain. | |
| 66 | + | if !super::inbound_sender_trusted( | |
| 67 | + | state.config.postmark_enforce_sender_auth, | |
| 68 | + | &payload.from_full.email, | |
| 69 | + | &payload.headers, | |
| 70 | + | ) { | |
| 71 | + | return HandlerOutcome::Terminal(StatusCode::OK); | |
| 72 | + | } | |
| 64 | 73 | let sender = match db::users::get_user_by_email(&state.db, &sender_email).await { | |
| 65 | 74 | Ok(Some(u)) if u.email_verified && !u.is_suspended() => u, | |
| 66 | 75 | Ok(Some(u)) if !u.email_verified => { | |
| @@ -246,6 +255,16 @@ async fn handle_issue_reply( | |||
| 246 | 255 | return HandlerOutcome::Terminal(StatusCode::OK); | |
| 247 | 256 | } | |
| 248 | 257 | }; | |
| 258 | + | // The signed reply token names the expected user, but the comment is authored | |
| 259 | + | // as `sender` — so the `From` must itself be SPF/DKIM-authenticated, not just | |
| 260 | + | // match the token, before we trust it (Run 13 sender-spoofing). | |
| 261 | + | if !super::inbound_sender_trusted( | |
| 262 | + | state.config.postmark_enforce_sender_auth, | |
| 263 | + | &payload.from_full.email, | |
| 264 | + | &payload.headers, | |
| 265 | + | ) { | |
| 266 | + | return HandlerOutcome::Terminal(StatusCode::OK); | |
| 267 | + | } | |
| 249 | 268 | let sender = match db::users::get_user_by_email(&state.db, &sender_email).await { | |
| 250 | 269 | Ok(Some(u)) if u.email_verified && !u.is_suspended() => u, | |
| 251 | 270 | Ok(Some(_)) => { |
| @@ -1,8 +1,11 @@ | |||
| 1 | 1 | //! Postmark webhook endpoints for bounce/complaint events, inbound patches, and inbound issues. | |
| 2 | 2 | ||
| 3 | + | mod auth_check; | |
| 3 | 4 | mod issues; | |
| 4 | 5 | mod patches; | |
| 5 | 6 | ||
| 7 | + | use auth_check::inbound_sender_trusted; | |
| 8 | + | ||
| 6 | 9 | use axum::{ | |
| 7 | 10 | extract::State, | |
| 8 | 11 | http::{HeaderMap, StatusCode}, |
| @@ -67,6 +67,16 @@ pub(super) async fn postmark_inbound( | |||
| 67 | 67 | return HandlerOutcome::Terminal(StatusCode::OK); | |
| 68 | 68 | } | |
| 69 | 69 | }; | |
| 70 | + | // Do not trust the attacker-controlled `From` as identity unless SPF/DKIM | |
| 71 | + | // aligns with its domain (Run 13 sender-spoofing). Terminal OK: retrying | |
| 72 | + | // can't authenticate a spoof, and a non-committal 200 is no existence oracle. | |
| 73 | + | if !super::inbound_sender_trusted( | |
| 74 | + | state.config.postmark_enforce_sender_auth, | |
| 75 | + | &payload.from_full.email, | |
| 76 | + | &payload.headers, | |
| 77 | + | ) { | |
| 78 | + | return HandlerOutcome::Terminal(StatusCode::OK); | |
| 79 | + | } | |
| 70 | 80 | let sender = match db::users::get_user_by_email(&state.db, &sender_email).await { | |
| 71 | 81 | Ok(Some(u)) if u.email_verified => u, | |
| 72 | 82 | Ok(Some(_)) => { |
| @@ -339,6 +339,16 @@ async fn checkout_seller_cart( | |||
| 339 | 339 | use db::promo_codes::PromoApplication; | |
| 340 | 340 | // Apply to each eligible paid item; ineligible items (scope/min-price) | |
| 341 | 341 | // are skipped so the rest of the cart can still qualify. | |
| 342 | + | // | |
| 343 | + | // A platform-wide credit (the $5 Fan+ renewal credit) is a monetary | |
| 344 | + | // BALANCE spent at most once across the whole cart, NOT a per-line | |
| 345 | + | // coupon: without a budget it would discount the buyer and reimburse the | |
| 346 | + | // seller once per eligible line — an N-times payout from a single-use | |
| 347 | + | // credit (ultra-fuzz Run 13 Payments SERIOUS). `credit_budget` is the | |
| 348 | + | // code's face value (`None` for seller-funded/percentage codes, which | |
| 349 | + | // have no balance to over-spend and stay per-line); each line's credit is | |
| 350 | + | // capped to the remainder and the uncovered discount reverts to the buyer. | |
| 351 | + | let mut credit_budget = validated.platform_credit_budget_cents(); | |
| 342 | 352 | for item in &paid_items { | |
| 343 | 353 | if item.pwyw_enabled { | |
| 344 | 354 | continue; // PWYW items can't take a promo (single-item behavior) | |
| @@ -346,10 +356,11 @@ async fn checkout_seller_cart( | |||
| 346 | 356 | if let PromoApplication::Apply(applied) = db::promo_codes::apply_promo_to_item( | |
| 347 | 357 | &validated, item.item_id, item.project_id, item.effective_price_cents(), | |
| 348 | 358 | )? { | |
| 349 | - | discounted_prices.insert(item.item_id, applied.price_cents); | |
| 350 | - | let credit = applied.funding.platform_credit_cents(); | |
| 359 | + | let (final_price, credit) = | |
| 360 | + | db::promo_codes::cap_line_to_credit_budget(applied, &mut credit_budget); | |
| 361 | + | discounted_prices.insert(item.item_id, final_price); | |
| 351 | 362 | if credit > 0 { | |
| 352 | - | platform_credits.insert(item.item_id, credit as i64); | |
| 363 | + | platform_credits.insert(item.item_id, credit); | |
| 353 | 364 | } | |
| 354 | 365 | } | |
| 355 | 366 | } |
| @@ -327,6 +327,7 @@ impl TestHarness { | |||
| 327 | 327 | cdn_base_url: opts.cdn_base_url.clone(), | |
| 328 | 328 | user_pages_host: std::sync::Arc::from("u.localhost"), | |
| 329 | 329 | postmark_inbound_webhook_token: opts.postmark_inbound_webhook_token, | |
| 330 | + | postmark_enforce_sender_auth: true, | |
| 330 | 331 | internal_shared_secret: opts.internal_shared_secret.clone(), | |
| 331 | 332 | cli_service_token: opts.cli_service_token.clone(), | |
| 332 | 333 | wam_url: None, |
| @@ -78,6 +78,7 @@ pub async fn run(config: LoadConfig) { | |||
| 78 | 78 | cdn_base_url: None, | |
| 79 | 79 | user_pages_host: std::sync::Arc::from("u.localhost"), | |
| 80 | 80 | postmark_inbound_webhook_token: None, | |
| 81 | + | postmark_enforce_sender_auth: true, | |
| 81 | 82 | internal_shared_secret: None, | |
| 82 | 83 | cli_service_token: None, | |
| 83 | 84 | wam_url: None, |