aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 1 | use std::{sync::Arc, time::Duration}; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 2 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 3 | use aws_lc_rs::digest::{SHA256, digest}; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 4 | use base64::{ |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 5 | Engine, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 6 | engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD}, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 7 | }; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 8 | use reqwest::{StatusCode, header}; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 9 | use serde::{Deserialize, Serialize}; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 10 | use url::Url; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 11 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 12 | use crate::{ |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 13 | ece::{self, EceError}, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 14 | vapid::Vapid, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 15 | }; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 16 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 17 | const PUSH_HOSTS: &[&str] = &[ |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 18 | "*.push.apple.com", |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 19 | "fcm.googleapis.com", |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 20 | "updates.push.services.mozilla.com", |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 21 | "*.notify.windows.com", |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 22 | ]; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 23 | const ALLOW_HOSTS_ENV: &str = "MALICE_PUSH_ALLOW_HOSTS"; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 24 | const MAX_ENDPOINT_LEN: usize = 2048; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 25 | const TTL_SECS: u32 = 12 * 60 * 60; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 26 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 27 | /// A browser's `PushSubscription`, keys as the browser's `toJSON` gives them. |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 28 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 29 | pub struct Subscription { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 30 | pub endpoint: String, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 31 | pub p256dh: String, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 32 | pub auth: String, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 33 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 34 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 35 | impl Subscription { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 36 | /// Whether the keys decode to a P-256 point and a 16-byte secret. |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 37 | pub fn keys_valid(&self) -> bool { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 38 | matches!( |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 39 | (decode_key(&self.p256dh), decode_key(&self.auth)), |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 40 | (Some(p256dh), Some(auth)) if p256dh.len() == 65 && p256dh[0] == 4 && auth.len() == 16 |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 41 | ) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 42 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 43 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 44 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 45 | fn decode_key(value: &str) -> Option<Vec<u8>> { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 46 | let value = value.trim().trim_end_matches('='); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 47 | URL_SAFE_NO_PAD |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 48 | .decode(value) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 49 | .or_else(|_| STANDARD_NO_PAD.decode(value)) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 50 | .ok() |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 51 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 52 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 53 | /// Push service hosts this server will POST to, so a pusher can't aim it at |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 54 | /// an arbitrary URL. |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 55 | #[derive(Debug, Clone, Default)] |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 56 | pub struct Allowlist { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 57 | /// `host:port` authorities also allowed over plain http, for local testing |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 58 | extra: Vec<String>, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 59 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 60 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 61 | impl Allowlist { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 62 | /// The built-in push services, plus any `host:port` entries in |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 63 | /// `MALICE_PUSH_ALLOW_HOSTS`. |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 64 | pub fn from_env() -> Self { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 65 | let extra = std::env::var(ALLOW_HOSTS_ENV) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 66 | .unwrap_or_default() |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 67 | .split(',') |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 68 | .map(|host| host.trim().to_ascii_lowercase()) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 69 | .filter(|host| !host.is_empty()) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 70 | .collect::<Vec<_>>(); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 71 | if !extra.is_empty() { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 72 | tracing::warn!(hosts = ?extra, "{ALLOW_HOSTS_ENV} allows extra push hosts, for testing only"); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 73 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 74 | Self { extra } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 75 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 76 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 77 | pub fn with_extra(extra: impl IntoIterator<Item = String>) -> Self { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 78 | Self { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 79 | extra: extra.into_iter().collect(), |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 80 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 81 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 82 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 83 | pub fn check(&self, endpoint: &str) -> Option<Url> { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 84 | if endpoint.len() > MAX_ENDPOINT_LEN { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 85 | return None; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 86 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 87 | let url = Url::parse(endpoint).ok()?; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 88 | if !url.username().is_empty() || url.password().is_some() { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 89 | return None; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 90 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 91 | let port = url.port_or_known_default()?; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 92 | let builtin = url.scheme() == "https" |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 93 | && port == 443 |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 94 | && url.domain().is_some_and(|host| { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 95 | PUSH_HOSTS |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 96 | .iter() |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 97 | .any(|pattern| match pattern.strip_prefix('*') { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 98 | Some(suffix) => host.len() > suffix.len() && host.ends_with(suffix), |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 99 | None => host == *pattern, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 100 | }) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 101 | }); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 102 | let extra = matches!(url.scheme(), "http" | "https") |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 103 | && self.extra.contains(&format!("{}:{port}", url.host_str()?)); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 104 | (builtin || extra).then_some(url) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 105 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 106 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 107 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 108 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 109 | pub enum Urgency { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 110 | Normal, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 111 | High, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 112 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 113 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 114 | pub struct Message<'a> { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 115 | pub payload: &'a [u8], |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 116 | pub urgency: Urgency, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 117 | /// pushes sharing a topic replace each other while still queued |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 118 | pub topic: &'a str, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 119 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 120 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 121 | #[derive(Debug, Clone, PartialEq, Eq)] |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 122 | pub enum Outcome { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 123 | Delivered, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 124 | /// the push service no longer knows the subscription (404 or 410) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 125 | Gone, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 126 | /// the endpoint is not an allowed push service, or the keys are unusable |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 127 | Refused, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 128 | Retryable, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 129 | Failed(String), |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 130 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 131 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 132 | impl Outcome { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 133 | pub fn drops_subscription(&self) -> bool { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 134 | matches!(self, Self::Gone | Self::Refused) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 135 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 136 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 137 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 138 | /// Encrypts and delivers pushes, signed with this server's VAPID key. |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 139 | #[derive(Clone)] |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 140 | pub struct Sender { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 141 | http: reqwest::Client, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 142 | vapid: Arc<Vapid>, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 143 | contact: String, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 144 | allow: Allowlist, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 145 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 146 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 147 | impl Sender { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 148 | /// `contact` is the VAPID `sub`, a `mailto:` or `https:` URL. |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 149 | pub fn new(vapid: Vapid, contact: String, allow: Allowlist) -> Result<Self, String> { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 150 | let http = reqwest::Client::builder() |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 151 | .redirect(reqwest::redirect::Policy::none()) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 152 | .connect_timeout(Duration::from_secs(5)) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 153 | .timeout(Duration::from_secs(10)) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 154 | .build() |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 155 | .map_err(|error| format!("building the push client: {error}"))?; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 156 | Ok(Self { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 157 | http, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 158 | vapid: Arc::new(vapid), |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 159 | contact, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 160 | allow, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 161 | }) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 162 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 163 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 164 | pub fn vapid_public_key(&self) -> &str { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 165 | self.vapid.public_key() |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 166 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 167 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 168 | pub fn allows(&self, endpoint: &str) -> bool { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 169 | self.allow.check(endpoint).is_some() |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 170 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 171 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 172 | /// Sends once, and once more after a second if the push service asks to retry. |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 173 | pub async fn send(&self, subscription: &Subscription, message: &Message<'_>) -> Outcome { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 174 | let Some(url) = self.allow.check(&subscription.endpoint) else { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 175 | return Outcome::Refused; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 176 | }; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 177 | let (Some(p256dh), Some(auth)) = ( |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 178 | decode_key(&subscription.p256dh), |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 179 | decode_key(&subscription.auth), |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 180 | ) else { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 181 | return Outcome::Refused; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 182 | }; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 183 | let body = match ece::encrypt(&p256dh, &auth, message.payload) { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 184 | Ok(body) => body, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 185 | Err(EceError::BadKey) => return Outcome::Refused, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 186 | Err(error) => return Outcome::Failed(error.to_string()), |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 187 | }; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 188 | let authorization = match self |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 189 | .vapid |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 190 | .authorization(&url.origin().ascii_serialization(), &self.contact) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 191 | { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 192 | Ok(authorization) => authorization, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 193 | Err(error) => return Outcome::Failed(error), |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 194 | }; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 195 | let topic = topic(message.topic); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 196 | let urgency = match message.urgency { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 197 | Urgency::Normal => "normal", |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 198 | Urgency::High => "high", |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 199 | }; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 200 | for attempt in 0..2 { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 201 | if attempt > 0 { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 202 | tokio::time::sleep(Duration::from_secs(1)).await; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 203 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 204 | let response = self |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 205 | .http |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 206 | .post(url.clone()) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 207 | .header(header::AUTHORIZATION, &authorization) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 208 | .header(header::CONTENT_ENCODING, "aes128gcm") |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 209 | .header(header::CONTENT_TYPE, "application/octet-stream") |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 210 | .header("TTL", TTL_SECS) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 211 | .header("Urgency", urgency) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 212 | .header("Topic", &topic) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 213 | .body(body.clone()) |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 214 | .send() |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 215 | .await; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 216 | let outcome = match response { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 217 | Ok(response) => classify(response.status()), |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 218 | Err(error) => { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 219 | tracing::debug!(%error, "push service unreachable"); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 220 | Outcome::Retryable |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 221 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 222 | }; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 223 | if outcome != Outcome::Retryable { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 224 | return outcome; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 225 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 226 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 227 | Outcome::Retryable |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 228 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 229 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 230 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 231 | fn classify(status: StatusCode) -> Outcome { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 232 | match status { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 233 | status if status.is_success() => Outcome::Delivered, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 234 | StatusCode::NOT_FOUND | StatusCode::GONE => Outcome::Gone, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 235 | StatusCode::TOO_MANY_REQUESTS => Outcome::Retryable, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 236 | status if status.is_server_error() => Outcome::Retryable, |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 237 | status => Outcome::Failed(format!("push service answered {status}")), |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 238 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 239 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 240 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 241 | /// RFC 8030 limits a topic to 32 characters of the base64url alphabet. |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 242 | fn topic(raw: &str) -> String { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 243 | let mut topic = URL_SAFE_NO_PAD.encode(digest(&SHA256, raw.as_bytes())); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 244 | topic.truncate(32); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 245 | topic |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 246 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 247 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 248 | #[cfg(test)] |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 249 | mod tests { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 250 | use super::*; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 251 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 252 | #[test] |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 253 | fn allowlist_admits_push_services_only() { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 254 | let allow = Allowlist::default(); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 255 | assert!( |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 256 | allow |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 257 | .check("https://fcm.googleapis.com/fcm/send/abc") |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 258 | .is_some() |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 259 | ); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 260 | assert!(allow.check("https://web.push.apple.com/QGx").is_some()); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 261 | assert!( |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 262 | allow |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 263 | .check("https://wns2-par02p.notify.windows.com/w/?token=x") |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 264 | .is_some() |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 265 | ); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 266 | assert!( |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 267 | allow |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 268 | .check("https://updates.push.services.mozilla.com/wpush/v2/x") |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 269 | .is_some() |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 270 | ); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 271 | assert!(allow.check("http://fcm.googleapis.com/x").is_none()); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 272 | assert!(allow.check("https://fcm.googleapis.com:8443/x").is_none()); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 273 | assert!(allow.check("https://push.apple.com/x").is_none()); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 274 | assert!(allow.check("https://evilpush.apple.com/x").is_none()); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 275 | assert!( |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 276 | allow |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 277 | .check("https://fcm.googleapis.com.evil.org/x") |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 278 | .is_none() |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 279 | ); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 280 | assert!(allow.check("https://user@fcm.googleapis.com/x").is_none()); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 281 | assert!(allow.check("https://127.0.0.1/x").is_none()); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 282 | assert!(allow.check("http://127.0.0.1:9000/x").is_none()); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 283 | let allow = Allowlist::with_extra(["localhost:9000".to_owned()]); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 284 | assert!(allow.check("http://localhost:9000/x").is_some()); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 285 | assert!(allow.check("http://localhost:9001/x").is_none()); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 286 | let allow = Allowlist::with_extra(["127.0.0.1:9000".to_owned()]); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 287 | assert!(allow.check("http://127.0.0.1:9000/x").is_some()); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 288 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 289 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 290 | #[test] |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 291 | fn topics_fit_rfc8030() { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 292 | let topic = topic("!room:example.org"); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 293 | assert_eq!(topic.len(), 32); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 294 | assert!( |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 295 | topic |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 296 | .chars() |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 297 | .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 298 | ); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 299 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 300 | |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 301 | #[test] |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 302 | fn keys_validate() { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 303 | let good = Subscription { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 304 | endpoint: String::new(), |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 305 | p256dh: "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4".to_owned(), |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 306 | auth: "BTBZMqHH6r4Tts7J_aSIgg==".to_owned(), |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 307 | }; |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 308 | assert!(good.keys_valid()); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 309 | assert!( |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 310 | !Subscription { |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 311 | auth: "short".to_owned(), |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 312 | ..good |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 313 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 314 | .keys_valid() |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 315 | ); |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 316 | } |
aa3dce5114d0 | atagen | 2026-10-26 06:09 UTC | 317 | } |