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