feat(push): Add MSC4174 web push

This commit is contained in:
Erwan Leboucher authored and Ellis Git committed 2026-09-21 23:35:05 +00:00
1 parent dbcffe8af0
commit ed1e634579
23 files changed
+1864 -281

No files matched your search

+3
View File
@@ -88,6 +88,7 @@ futures.workspace = true
governor.workspace = true
hickory-resolver.workspace = true
http.workspace = true
httpdate.workspace = true
image.workspace = true
image.optional = true
ipaddress.workspace = true
@@ -96,6 +97,7 @@ log.workspace = true
loole.workspace = true
lru-cache.workspace = true
nonzero_ext.workspace = true
p256.workspace = true
regex.workspace = true
reqwest.workspace = true
assign.workspace = true
@@ -112,6 +114,7 @@ termimad.optional = true
tokio.workspace = true
tracing.workspace = true
url.workspace = true
web-push-native.workspace = true
webpage.workspace = true
webpage.optional = true
recaptcha-verify = { version = "0.2.0", default-features = false }
+11
View File
@@ -24,6 +24,7 @@ pub struct Service {
pub sender: reqwest::Client,
pub appservice: reqwest::Client,
pub pusher: reqwest::Client,
pub webpush: reqwest::Client,
pub cidr_range_denylist: Vec<IPAddress>,
}
@@ -141,6 +142,16 @@ impl crate::Service for Service {
.redirect(redirect::Policy::limited(2))
.build()?,
// MSC4174 recommends against following redirects, since the destination
// is only checked before the request is sent.
webpush: base(config)?
.connect_timeout(Duration::from_secs(config.pusher_conn_timeout))
.timeout(Duration::from_secs(config.pusher_timeout))
.pool_max_idle_per_host(1)
.pool_idle_timeout(Duration::from_secs(config.pusher_idle_timeout))
.redirect(redirect::Policy::none())
.build()?,
cidr_range_denylist: config
.ip_range_denylist
.iter()
-13
View File
@@ -6,7 +6,6 @@ use conduwuit::{
config::{Config, check},
error,
};
use url::Url;
use crate::registration_tokens::{ValidToken, ValidTokenSource};
@@ -53,18 +52,6 @@ impl Service {
.map(|token| ValidToken { token, source: ValidTokenSource::Config })
}
/// Get the base domain to use for user-facing URLs.
#[must_use]
pub fn get_client_domain(&self) -> Url {
self.well_known.client.clone().unwrap_or_else(|| {
let host = self.server_name.host();
format!("https://{host}")
.as_str()
.try_into()
.expect("server name should be a valid host")
})
}
fn handle_reload(&self) -> Result {
if self.server.config.config_reload_signal {
#[cfg(all(feature = "systemd", target_os = "linux"))]
+292 -226
View File
@@ -1,9 +1,11 @@
mod webpush;
use std::{fmt::Debug, mem, sync::Arc};
use bytes::BytesMut;
use conduwuit::utils::response::LimitReadExt;
use conduwuit_core::{
Err, Event, Result, debug_warn, err, trace,
Err, Event, Result, Server, debug_warn, err, trace,
utils::{stream::TryIgnore, string_from_bytes},
warn,
};
@@ -15,7 +17,6 @@ use ruma::{
api::{
IncomingResponseExt, OutgoingRequest, OutgoingRequestExt,
auth_scheme::NoAuthentication,
client::push::{Pusher, PusherKind, set_pusher},
path_builder::SinglePath,
push_gateway::send_event_notification::{
self,
@@ -27,18 +28,33 @@ use ruma::{
Action, HighlightTweakValue, PushConditionPowerLevelsCtx, PushConditionRoomCtx,
PushFormat, Ruleset, Tweak,
},
serde::Raw,
serde::{JsonObject, Raw},
uint,
};
use ruminuwuity::pushers::{Pusher, PusherKind, set_pusher::v3::PusherAction};
pub use self::webpush::ActivationOutcome;
use self::webpush::Vapid;
use crate::{Dep, client, config, globals, rooms, sending, users};
const PUSH_GATEWAY_SCHEMES: &[&str] = &["http", "https"];
const WEBPUSH_SCHEMES: &[&str] = &["https"];
/// Whether the pusher opted out of badge counts (MSC4076).
pub(crate) fn badge_counts_disabled(data: &JsonObject) -> bool {
data.contains_key("org.matrix.msc4076.disable_badge_count")
|| data.contains_key("disable_badge_count")
}
pub struct Service {
db: Data,
services: Services,
me: std::sync::Weak<Self>,
vapid: Vapid,
}
struct Services {
server: Arc<Server>,
globals: Dep<globals::Service>,
config: Dep<config::Service>,
client: Dep<client::Service>,
@@ -50,17 +66,24 @@ struct Services {
struct Data {
senderkey_pusher: Arc<Map>,
senderkey_webpushbackoff: Arc<Map>,
senderkey_webpushstate: Arc<Map>,
pushkey_deviceid: Arc<Map>,
}
impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
let vapid = Vapid::init(args.db, args.server.config.webpush_vapid_contact().to_string())?;
Ok(Arc::new_cyclic(|me| Self {
db: Data {
senderkey_pusher: args.db["senderkey_pusher"].clone(),
senderkey_webpushbackoff: args.db["senderkey_webpushbackoff"].clone(),
senderkey_webpushstate: args.db["senderkey_webpushstate"].clone(),
pushkey_deviceid: args.db["pushkey_deviceid"].clone(),
},
services: Services {
server: args.server.clone(),
globals: args.depend::<globals::Service>("globals"),
client: args.depend::<client::Service>("client"),
config: args.depend::<config::Service>("config"),
@@ -70,6 +93,8 @@ impl crate::Service for Service {
users: args.depend::<users::Service>("users"),
sending: args.depend::<sending::Service>("sending"),
},
me: me.clone(),
vapid,
}))
}
@@ -81,71 +106,87 @@ impl Service {
&self,
sender: &UserId,
sender_device: &DeviceId,
pusher: &set_pusher::v3::PusherAction,
) -> Result {
match pusher {
| set_pusher::v3::PusherAction::Post(data) => {
let pushkey = data.pusher.ids.pushkey.as_str();
if pushkey.len() > 512 {
return Err!(Request(InvalidParam(
"Push key length cannot be greater than 512 bytes."
)));
}
if data.pusher.ids.app_id.as_str().len() > 64 {
return Err!(Request(InvalidParam(
"App ID length cannot be greater than 64 bytes."
)));
}
// add some validation to the pusher URL
let pusher_kind = &data.pusher.kind;
if let PusherKind::Http(http) = pusher_kind {
let url = &http.url;
let url = url::Url::parse(&http.url).map_err(|e| {
err!(Request(InvalidParam(
warn!(%url, "HTTP pusher URL is not a valid URL: {e}")
)))
})?;
if ["http", "https"]
.iter()
.all(|&scheme| scheme != url.scheme().to_lowercase())
{
return Err!(Request(InvalidParam(
warn!(%url, "HTTP pusher URL is not a valid HTTP/HTTPS URL")
)));
}
if let Ok(ip) =
IPAddress::parse(url.host_str().expect("URL previously validated"))
{
if !self.services.client.valid_cidr_range(&ip) {
return Err!(Request(InvalidParam(
warn!(%url, "HTTP pusher URL is a forbidden remote address")
)));
}
}
}
let pushkey = data.pusher.ids.pushkey.as_str();
let key = (sender, pushkey);
self.db.senderkey_pusher.put(key, Json(pusher));
self.db.pushkey_deviceid.insert(pushkey, sender_device);
},
| set_pusher::v3::PusherAction::Delete(ids) => {
action: &PusherAction,
) -> Result<bool> {
let pusher = match action {
| PusherAction::Post(pusher) => pusher,
| PusherAction::Delete(ids) => {
self.delete_pusher(sender, ids.pushkey.as_str()).await;
return Ok(false);
},
| _ => return Err!(Request(InvalidParam("Unknown pusher action"))),
};
let pushkey = pusher.ids.pushkey.as_str();
if pushkey.len() > 512 {
return Err!(Request(InvalidParam(
"Push key length cannot be greater than 512 bytes."
)));
}
Ok(())
if pusher.ids.app_id.as_str().len() > 64 {
return Err!(Request(InvalidParam("App ID length cannot be greater than 64 bytes.")));
}
match &pusher.kind {
| PusherKind::Http(http) => {
self.validate_push_url(&http.url, PUSH_GATEWAY_SCHEMES)?;
},
| PusherKind::WebPush(data) => {
self.validate_push_url(&data.url, WEBPUSH_SCHEMES)?;
webpush::decode_pushkey(pushkey)?;
webpush::decode_auth(&data.auth)?;
if let Some(default_payload) = data.data.get("default_payload") {
if serde_json::to_vec(default_payload)?.len() > webpush::MAX_DEFAULT_PAYLOAD {
return Err!(Request(TooLarge(
"Default payload cannot be greater than {} bytes.",
webpush::MAX_DEFAULT_PAYLOAD
)));
}
}
},
| PusherKind::Email(_) | PusherKind::Custom { .. } => (),
}
let key = (sender, pushkey);
let previous: Option<Pusher> = self
.db
.senderkey_pusher
.qry(&key)
.await
.deserialized()
.map_err(|e| {
if !e.is_not_found() {
warn!(%pushkey, "Replacing a stored pusher that could not be read: {e}");
}
})
.ok();
let needs_activation = self
.webpush_needs_activation(sender, pusher, previous.as_ref())
.await;
if !needs_activation && pusher.kind.as_webpush().is_none() {
self.db.senderkey_webpushstate.del(key);
self.db.senderkey_webpushbackoff.del(key);
}
self.db.senderkey_pusher.put(key, Json(pusher));
self.db.pushkey_deviceid.insert(pushkey, sender_device);
// Must run after the put: it delivers straight away, and a rejection
// from the push service deletes the pusher it is validating.
if needs_activation {
self.begin_webpush_activation(sender, pusher);
}
Ok(needs_activation)
}
pub async fn delete_pusher(&self, sender: &UserId, pushkey: &str) {
let key = (sender, pushkey);
self.db.senderkey_pusher.del(key);
self.db.senderkey_webpushbackoff.del(key);
self.db.senderkey_webpushstate.del(key);
self.db.pushkey_deviceid.remove(pushkey);
self.services
@@ -192,6 +233,35 @@ impl Service {
.map(ToOwned::to_owned)
}
/// Checks that a pusher URL is well-formed and not pointing at a forbidden
/// address.
fn validate_push_url(&self, url: &str, allowed: &[&str]) -> Result<url::Url> {
let parsed = url::Url::parse(url).map_err(|e| {
err!(Request(InvalidParam(warn!(%url, "Pusher URL is not a valid URL: {e}"))))
})?;
let scheme = parsed.scheme().to_lowercase();
if !allowed.contains(&scheme.as_str()) {
return Err!(Request(InvalidParam(warn!(
%url,
"Pusher URL scheme {scheme} is not allowed"
))));
}
if let Some(host) = parsed.host_str() {
if let Ok(ip) = IPAddress::parse(host) {
if !self.services.client.valid_cidr_range(&ip) {
return Err!(Request(InvalidParam(warn!(
%url,
"Pusher URL is a forbidden remote address"
))));
}
}
}
Ok(parsed)
}
#[tracing::instrument(skip(self, dest, request))]
pub async fn send_request<T>(&self, dest: &str, request: T) -> Result<T::IncomingResponse>
where
@@ -211,9 +281,41 @@ impl Service {
})?
.map(BytesMut::freeze);
let reqwest_request = reqwest::Request::try_from(http_request)?;
let response = self
.execute_pusher_request(
&self.services.client.pusher,
reqwest::Request::try_from(http_request)?,
)
.await?;
if let Some(url_host) = reqwest_request.url().host_str() {
let status = response.status();
if !status.is_success() {
debug_warn!("Push gateway response body: {:?}", string_from_bytes(response.body()));
return Err!(BadServerResponse(warn!(
"Push gateway {dest} returned unsuccessful HTTP response: {status}"
)));
}
let (parts, body) = response.into_parts();
T::IncomingResponse::try_from_http_response(http::Response::from_parts(
parts,
body.as_ref(),
))
.map_err(|e| {
err!(BadServerResponse(warn!("Push gateway {dest} returned invalid response: {e}")))
})
}
/// Executes a request, checking the destination address on the way out and
/// on the way in. The status is returned as-is; callers decide which codes
/// are failures.
async fn execute_pusher_request(
&self,
client: &reqwest::Client,
request: reqwest::Request,
) -> Result<http::Response<Vec<u8>>> {
if let Some(url_host) = request.url().host_str() {
trace!("Checking request URL for IP");
if let Ok(ip) = IPAddress::parse(url_host) {
if !self.services.client.valid_cidr_range(&ip) {
@@ -222,69 +324,36 @@ impl Service {
}
}
let response = self.services.client.pusher.execute(reqwest_request).await;
let dest = request.url().clone();
let mut response = client.execute(request).await.map_err(|e| {
err!(BadServerResponse(warn!(%dest, "Could not send request to pusher: {e}")))
})?;
match response {
| Ok(mut response) => {
// reqwest::Response -> http::Response conversion
trace!("Checking response destination's IP");
if let Some(remote_addr) = response.remote_addr() {
if let Ok(ip) = IPAddress::parse(remote_addr.ip().to_string()) {
if !self.services.client.valid_cidr_range(&ip) {
return Err!(BadServerResponse(
"Not allowed to send requests to this IP"
));
}
}
trace!("Checking response destination's IP");
if let Some(remote_addr) = response.remote_addr() {
if let Ok(ip) = IPAddress::parse(remote_addr.ip().to_string()) {
if !self.services.client.valid_cidr_range(&ip) {
return Err!(BadServerResponse("Not allowed to send requests to this IP"));
}
let status = response.status();
let mut http_response_builder = http::Response::builder()
.status(status)
.version(response.version());
mem::swap(
response.headers_mut(),
http_response_builder
.headers_mut()
.expect("http::response::Builder is usable"),
);
let body = response
.limit_read(
self.services
.config
.max_request_size
.try_into()
.expect("usize fits into u64"),
)
.await?;
if !status.is_success() {
debug_warn!("Push gateway response body: {:?}", string_from_bytes(&body));
return Err!(BadServerResponse(warn!(
"Push gateway {dest} returned unsuccessful HTTP response: {status}"
)));
}
let (parts, body) = http_response_builder
.body(body)
.expect("reqwest body is valid http body")
.into_parts();
let response = T::IncomingResponse::try_from_http_response(
http::Response::from_parts(parts, body.as_ref()),
);
response.map_err(|e| {
err!(BadServerResponse(warn!(
"Push gateway {dest} returned invalid response: {e}"
)))
})
},
| Err(e) => {
warn!("Could not send request to pusher {dest}: {e}");
Err(e.into())
},
}
}
let mut result = http::Response::new(Vec::new());
*result.status_mut() = response.status();
*result.version_mut() = response.version();
mem::swap(response.headers_mut(), result.headers_mut());
*result.body_mut() = response
.limit_read(
self.services
.config
.max_request_size
.try_into()
.expect("usize fits into u64"),
)
.await?;
Ok(result)
}
#[tracing::instrument(skip(self, user, unread, pusher, ruleset, event))]
@@ -315,13 +384,7 @@ impl Service {
let serialized = event.to_format();
for action in self
.get_actions(
user,
&ruleset,
power_levels.clone(),
&serialized,
event.room_id().unwrap(),
)
.get_actions(user, &ruleset, power_levels.clone(), &serialized, room_id)
.await
{
let n = match action {
@@ -343,7 +406,8 @@ impl Service {
}
if notify == Some(true) {
self.send_notice(unread, pusher, tweaks, event).await?;
self.send_notice(user, unread, pusher, tweaks, event)
.await?;
}
// Else the event triggered no actions
@@ -406,6 +470,7 @@ impl Service {
#[tracing::instrument(skip(self, unread, pusher, tweaks, event))]
async fn send_notice<E>(
&self,
user: &UserId,
unread: UInt,
pusher: &Pusher,
tweaks: Vec<Tweak>,
@@ -417,109 +482,110 @@ impl Service {
// TODO: email
match &pusher.kind {
| PusherKind::Http(http) => {
let url = &http.url;
let url = url::Url::parse(&http.url).map_err(|e| {
err!(Request(InvalidParam(
warn!(%url, "HTTP pusher URL is not a valid URL: {e}")
)))
})?;
self.validate_push_url(&http.url, PUSH_GATEWAY_SCHEMES)?;
if ["http", "https"]
.iter()
.all(|&scheme| scheme != url.scheme().to_lowercase())
{
return Err!(Request(InvalidParam(
warn!(%url, "HTTP pusher URL is not a valid HTTP/HTTPS URL")
)));
}
if let Ok(ip) =
IPAddress::parse(url.host_str().expect("URL previously validated"))
{
if !self.services.client.valid_cidr_range(&ip) {
return Err!(Request(InvalidParam(
warn!(%url, "HTTP pusher URL is a forbidden remote address")
)));
}
}
// TODO (timo): can pusher/devices have conflicting formats
let event_id_only = http.format == Some(PushFormat::EventIdOnly);
let mut device =
Device::new(pusher.ids.app_id.clone(), pusher.ids.pushkey.clone());
device.data.data.clone_from(&http.data);
device.data.format.clone_from(&http.format);
// Tweaks are only added if the format is NOT event_id_only
if !event_id_only {
device.tweaks.clone_from(&tweaks);
}
let d = vec![device];
let mut notify = Notification::new(d);
notify.event_id = Some(event.event_id().to_owned());
notify.room_id = Some(event.room_id().unwrap().to_owned());
if http
.data
.get("org.matrix.msc4076.disable_badge_count")
.is_none() && http.data.get("disable_badge_count").is_none()
{
notify.counts = NotificationCounts::new(unread, uint!(0));
} else {
// counts will not be serialised if it's the default (0, 0)
// skip_serializing_if = "NotificationCounts::is_default"
notify.counts = NotificationCounts::default();
}
if !event_id_only {
if *event.kind() == TimelineEventType::RoomEncrypted
|| tweaks.iter().any(|t| {
matches!(
t,
Tweak::Highlight(HighlightTweakValue::Yes) | Tweak::Sound(_)
)
}) {
notify.prio = NotificationPriority::High;
} else {
notify.prio = NotificationPriority::Low;
}
notify.sender = Some(event.sender().to_owned());
notify.event_type = Some(event.kind().to_owned());
notify.content = serde_json::value::to_raw_value(event.content()).ok();
if *event.kind() == TimelineEventType::RoomMember {
notify.user_is_target =
event.state_key() == Some(event.sender().as_str());
}
notify.sender_display_name =
self.services.users.displayname(event.sender()).await.ok();
notify.room_name = self
.services
.state_accessor
.get_name(event.room_id().unwrap())
.await
.ok();
notify.room_alias = self
.services
.state_accessor
.get_canonical_alias(event.room_id().unwrap())
.await
.ok();
}
let notify = self
.build_notification(
pusher,
&http.data,
http.format.as_ref(),
&tweaks,
event,
unread,
)
.await;
self.send_request(&http.url, send_event_notification::v1::Request::new(notify))
.await?;
Ok(())
},
// TODO: Handle email
//PusherKind::Email(_) => Ok(()),
| _ => Ok(()),
| PusherKind::WebPush(data) => {
let notify = self
.build_notification(
pusher,
&data.data,
data.format.as_ref(),
&tweaks,
event,
unread,
)
.await;
self.send_webpush_notice(user, pusher, data, notify).await
},
| PusherKind::Email(_) | PusherKind::Custom { .. } => Ok(()),
}
}
async fn build_notification<E>(
&self,
pusher: &Pusher,
data: &JsonObject,
format: Option<&PushFormat>,
tweaks: &[Tweak],
event: &E,
unread: UInt,
) -> Notification
where
E: Event + Send + Sync,
{
// TODO (timo): can pusher/devices have conflicting formats
let event_id_only = format == Some(&PushFormat::EventIdOnly);
let mut device = Device::new(pusher.ids.app_id.clone(), pusher.ids.pushkey.clone());
device.data.data.clone_from(data);
device.data.format = format.cloned();
// Tweaks are only added if the format is NOT event_id_only
if !event_id_only {
device.tweaks = tweaks.to_owned();
}
let mut notify = Notification::new(vec![device]);
notify.event_id = Some(event.event_id().to_owned());
notify.room_id = event.room_id().map(ToOwned::to_owned);
if badge_counts_disabled(data) {
// counts will not be serialised if it's the default (0, 0)
// skip_serializing_if = "NotificationCounts::is_default"
notify.counts = NotificationCounts::default();
} else {
notify.counts = NotificationCounts::new(unread, uint!(0));
}
if !event_id_only {
if *event.kind() == TimelineEventType::RoomEncrypted
|| tweaks.iter().any(|t| {
matches!(t, Tweak::Highlight(HighlightTweakValue::Yes) | Tweak::Sound(_))
}) {
notify.prio = NotificationPriority::High;
} else {
notify.prio = NotificationPriority::Low;
}
notify.sender = Some(event.sender().to_owned());
notify.event_type = Some(event.kind().to_owned());
notify.content = serde_json::value::to_raw_value(event.content()).ok();
if *event.kind() == TimelineEventType::RoomMember {
notify.user_is_target = event.state_key() == Some(event.sender().as_str());
}
notify.sender_display_name =
self.services.users.displayname(event.sender()).await.ok();
if let Some(room_id) = event.room_id() {
notify.room_name = self.services.state_accessor.get_name(room_id).await.ok();
notify.room_alias = self
.services
.state_accessor
.get_canonical_alias(room_id)
.await
.ok();
}
}
notify
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Web Push key validation, token comparison, and payload shaping helpers.
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use conduwuit_core::{Err, Result, err};
use ruma::{api::push_gateway::send_event_notification::v1::Notification, serde::JsonObject};
use serde_json::Value as JsonValue;
use web_push_native::{Auth, p256::PublicKey};
use crate::pusher::badge_counts_disabled;
const MAX_PAYLOAD: usize = 3900;
const MAX_BODY_LENGTH: usize = 1000;
const MAX_CIPHERTEXT_LENGTH: usize = 2000;
pub(crate) fn decode_pushkey(pushkey: &str) -> Result<PublicKey> {
let bytes = URL_SAFE_NO_PAD
.decode(pushkey)
.map_err(|e| err!(Request(InvalidParam("Push key is not URL-safe base64: {e}"))))?;
if bytes.len() != 65 || bytes.first() != Some(&0x04) {
return Err!(Request(InvalidParam(
"Push key must be a P-256 public key in the uncompressed form"
)));
}
PublicKey::from_sec1_bytes(&bytes)
.map_err(|e| err!(Request(InvalidParam("Push key is not a valid P-256 public key: {e}"))))
}
pub(crate) fn decode_auth(auth: &str) -> Result<Auth> {
let bytes = URL_SAFE_NO_PAD
.decode(auth)
.map_err(|e| err!(Request(InvalidParam("Auth secret is not URL-safe base64: {e}"))))?;
let bytes: [u8; 16] = bytes
.try_into()
.map_err(|_| err!(Request(InvalidParam("Auth secret must be 16 bytes"))))?;
Ok(Auth::from(bytes))
}
pub(super) fn constant_time_eq(a: &str, b: &str) -> bool {
a.len() == b.len()
&& a.bytes()
.zip(b.bytes())
.fold(0_u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
pub(super) fn build_payload(notify: &Notification, data: &JsonObject) -> Result<Vec<u8>> {
let JsonValue::Object(mut payload) = serde_json::to_value(notify)? else {
return Err!("Notification did not serialize to an object");
};
payload.remove("devices");
if let Some(JsonValue::Object(counts)) = payload.remove("counts") {
for key in ["unread", "missed_calls"] {
if let Some(count) = counts.get(key) {
payload.insert(key.to_owned(), count.clone());
}
}
} else if !badge_counts_disabled(data) {
payload.insert("unread".to_owned(), 0.into());
}
if let Some(JsonValue::Object(default_payload)) = data.get("default_payload") {
for (key, value) in default_payload {
payload.insert(key.clone(), value.clone());
}
}
if let Some(JsonValue::Object(content)) = payload.get_mut("content") {
if let Some(JsonValue::String(body)) = content.get("body")
&& body.len() > MAX_BODY_LENGTH
{
content.insert(
"body".to_owned(),
format!("{}…", truncate_bytes(body, MAX_BODY_LENGTH)).into(),
);
}
if content
.get("ciphertext")
.and_then(JsonValue::as_str)
.is_some_and(|ciphertext| ciphertext.len() > MAX_CIPHERTEXT_LENGTH)
{
content.remove("ciphertext");
}
}
let mut encoded = serde_json::to_vec(&payload)?;
if encoded.len() > MAX_PAYLOAD {
payload.remove("content");
encoded = serde_json::to_vec(&payload)?;
}
if encoded.len() > MAX_PAYLOAD {
return Err!(Request(TooLarge("Web push payload is too large to deliver")));
}
Ok(encoded)
}
fn truncate_bytes(text: &str, max: usize) -> String {
let mut truncated = String::with_capacity(max);
for ch in text.chars() {
if truncated.len().saturating_add(ch.len_utf8()) > max {
break;
}
truncated.push(ch);
}
truncated
}
#[cfg(test)]
mod tests {
use ruma::{
api::push_gateway::send_event_notification::v1::{Notification, NotificationCounts},
push::PushFormat,
serde::JsonObject,
uint,
};
use serde_json::{Value as JsonValue, json};
use super::{MAX_BODY_LENGTH, build_payload, constant_time_eq};
fn payload_of(notify: &Notification, data: &JsonObject) -> JsonObject {
let encoded = build_payload(notify, data).unwrap();
match serde_json::from_slice(&encoded).unwrap() {
| JsonValue::Object(payload) => payload,
| other => panic!("expected an object, got {other}"),
}
}
fn notification_with_body(body: &str) -> Notification {
let mut notify = Notification::new(vec![]);
notify.content = Some(serde_json::value::to_raw_value(&json!({ "body": body })).unwrap());
notify
}
#[test]
fn flattens_counts_and_drops_devices() {
let mut notify = Notification::new(vec![]);
notify.counts = NotificationCounts::new(uint!(3), uint!(1));
let payload = payload_of(&notify, &JsonObject::new());
assert!(!payload.contains_key("devices"));
assert!(!payload.contains_key("counts"));
assert_eq!(payload.get("unread"), Some(&json!(3)));
assert_eq!(payload.get("missed_calls"), Some(&json!(1)));
}
#[test]
fn keeps_zero_unread_count() {
let payload = payload_of(&Notification::new(vec![]), &JsonObject::new());
assert_eq!(payload.get("unread"), Some(&json!(0)));
}
#[test]
fn omits_count_when_badges_are_disabled() {
let mut data = JsonObject::new();
data.insert("org.matrix.msc4076.disable_badge_count".to_owned(), json!(true));
let payload = payload_of(&Notification::new(vec![]), &data);
assert!(!payload.contains_key("unread"));
}
#[test]
fn merges_default_payload() {
let mut data = JsonObject::new();
data.insert("default_payload".to_owned(), json!({ "session_id": "abc" }));
data.insert("format".to_owned(), json!(PushFormat::EventIdOnly));
let payload = payload_of(&Notification::new(vec![]), &data);
assert_eq!(payload.get("session_id"), Some(&json!("abc")));
assert!(!payload.contains_key("format"));
}
#[test]
fn truncates_long_body() {
let payload = payload_of(&notification_with_body(&"a".repeat(2000)), &JsonObject::new());
let body = payload["content"]["body"].as_str().unwrap();
assert!(body.len() <= MAX_BODY_LENGTH + '…'.len_utf8());
assert!(body.ends_with('…'));
}
#[test]
fn truncates_multibyte_body_by_bytes() {
let payload = payload_of(&notification_with_body(&"é".repeat(2000)), &JsonObject::new());
let body = payload["content"]["body"].as_str().unwrap();
assert!(body.len() <= MAX_BODY_LENGTH + '…'.len_utf8());
assert!(body.chars().count() < 2000);
}
#[test]
fn rejects_payload_that_cannot_be_shrunk() {
let mut data = JsonObject::new();
data.insert("default_payload".to_owned(), json!({ "pad": "x".repeat(4000) }));
build_payload(&Notification::new(vec![]), &data).unwrap_err();
}
#[test]
fn token_comparison() {
assert!(constant_time_eq("abc", "abc"));
assert!(!constant_time_eq("abc", "abz"));
assert!(!constant_time_eq("abc", "abcd"));
}
}
+432
View File
@@ -0,0 +1,432 @@
mod helpers;
mod vapid;
use std::time::{Duration, SystemTime};
use conduwuit_core::{Err, Result, debug, debug_warn, err, utils, warn};
use conduwuit_database::{Deserialized, Json};
use futures::StreamExt;
pub(super) use helpers::{decode_auth, decode_pushkey};
use http::StatusCode;
use ruma::{
UserId,
api::push_gateway::send_event_notification::v1::{Notification, NotificationPriority},
push::PushFormat,
};
use ruminuwuity::pushers::{Pusher, WebPushPusherData};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::time::sleep;
pub(super) use vapid::Vapid;
use web_push_native::WebPushBuilder;
use self::helpers::{build_payload, constant_time_eq};
use super::{Service, WEBPUSH_SCHEMES};
const TTL: Duration = Duration::from_hours(48);
const ACK_TOKEN_TTL_MS: u64 = 5 * 60 * 1000;
const ACK_TOKEN_LENGTH: usize = 32;
pub(super) const MAX_DEFAULT_PAYLOAD: usize = 1024;
const DELIVERY_ATTEMPTS: u32 = 3;
const RETRY_BASE: Duration = Duration::from_secs(2);
const RETRY_MAX: Duration = Duration::from_secs(20);
const BACKOFF_BASE: Duration = Duration::from_mins(1);
const BACKOFF_MAX: Duration = Duration::from_hours(24);
#[derive(Clone, Copy, Debug)]
pub enum ActivationOutcome {
Activated,
NoSuchPusher,
UnknownToken,
Expired,
}
enum Attempt {
Delivered,
/// The subscription is gone; delete the pusher.
Rejected,
/// Transient, carrying the delay the push service asked for.
Failed(Option<Duration>),
}
#[derive(Clone, Default, Deserialize, Serialize)]
pub(super) struct WebPushState {
pub(super) activated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) ack_token: Option<String>,
#[serde(default)]
pub(super) token_expires_at: u64,
}
#[derive(Clone, Default, Deserialize, Serialize)]
struct WebPushBackoff {
#[serde(default)]
next_attempt_at: u64,
#[serde(default)]
failure_count: u32,
}
impl Service {
pub fn webpush_vapid_public_key(&self) -> &str { self.vapid.public_key() }
/// Reports whether a Web Push pusher has completed its activation
/// handshake.
pub async fn is_webpush_activated(&self, sender: &UserId, pushkey: &str) -> bool {
self.webpush_state(sender, pushkey).await.activated
}
pub(super) async fn webpush_state(&self, sender: &UserId, pushkey: &str) -> WebPushState {
self.db
.senderkey_webpushstate
.qry(&(sender, pushkey))
.await
.deserialized()
.unwrap_or_default()
}
fn put_webpush_state(&self, sender: &UserId, pushkey: &str, state: &WebPushState) {
self.db
.senderkey_webpushstate
.put((sender, pushkey), Json(state));
}
pub(super) async fn webpush_needs_activation(
&self,
sender: &UserId,
pusher: &Pusher,
previous: Option<&Pusher>,
) -> bool {
let Some(data) = pusher.kind.as_webpush() else {
return false;
};
let Some(old) = previous.and_then(|previous| previous.kind.as_webpush()) else {
return true;
};
old.url != data.url
|| old.auth != data.auth
|| !self
.webpush_state(sender, pusher.ids.pushkey.as_str())
.await
.activated
}
pub(super) fn begin_webpush_activation(&self, sender: &UserId, pusher: &Pusher) {
// Send a one-time encrypted token before allowing notification
// delivery.
let token = utils::rand::string(ACK_TOKEN_LENGTH);
let state = WebPushState {
activated: false,
ack_token: Some(token.clone()),
token_expires_at: utils::millis_since_unix_epoch().saturating_add(ACK_TOKEN_TTL_MS),
};
self.put_webpush_state(sender, pusher.ids.pushkey.as_str(), &state);
self.db
.senderkey_webpushbackoff
.del((sender, pusher.ids.pushkey.as_str()));
let Some(service) = self.me.upgrade() else {
return;
};
let sender = sender.to_owned();
let pusher = pusher.clone();
let payload = json!({ "app_id": pusher.ids.app_id, "ack_token": token });
self.services.server.runtime().spawn(async move {
let Some(data) = pusher.kind.as_webpush() else {
return;
};
if let Err(e) = service
.deliver_webpush(
&sender,
&pusher,
data,
payload.to_string().into_bytes(),
NotificationPriority::High,
)
.await
{
debug_warn!(
%sender, app_id = %pusher.ids.app_id,
"Failed to send web push validation token: {e}"
);
}
});
}
#[tracing::instrument(skip(self, ack_token), level = "debug")]
/// Activates the pusher whose pending token matches the client
/// acknowledgement.
pub async fn activate_webpush_pusher(
&self,
sender: &UserId,
app_id: &str,
ack_token: &str,
) -> Result<ActivationOutcome> {
let candidates: Vec<String> = self
.get_pushkeys(sender)
.filter_map(async |pushkey| {
let pusher = self.get_pusher(sender, &pushkey).await.ok()?;
(pusher.kind.as_webpush().is_some() && pusher.ids.app_id == app_id)
.then_some(pushkey)
})
.collect()
.await;
if candidates.is_empty() {
return Ok(ActivationOutcome::NoSuchPusher);
}
for pushkey in candidates {
let mut state = self.webpush_state(sender, &pushkey).await;
if !state
.ack_token
.as_deref()
.is_some_and(|token| constant_time_eq(token, ack_token))
{
continue;
}
state.ack_token = None;
if state.token_expires_at < utils::millis_since_unix_epoch() {
self.put_webpush_state(sender, &pushkey, &state);
return Ok(ActivationOutcome::Expired);
}
state.activated = true;
self.put_webpush_state(sender, &pushkey, &state);
self.db.senderkey_webpushbackoff.del((sender, &pushkey));
return Ok(ActivationOutcome::Activated);
}
Ok(ActivationOutcome::UnknownToken)
}
#[tracing::instrument(skip(self, pusher, data, notify), level = "debug")]
/// Delivers a notification only after the pusher has completed activation.
pub(super) async fn send_webpush_notice(
&self,
user: &UserId,
pusher: &Pusher,
data: &WebPushPusherData,
notify: Notification,
) -> Result {
let pushkey = pusher.ids.pushkey.as_str();
if !self.webpush_state(user, pushkey).await.activated {
debug!(%user, %pushkey, "Web push pusher is not activated yet, skipping");
return Ok(());
}
let backoff: WebPushBackoff = self
.db
.senderkey_webpushbackoff
.qry(&(user, pushkey))
.await
.deserialized()
.unwrap_or_default();
if utils::millis_since_unix_epoch() < backoff.next_attempt_at {
debug!(%user, %pushkey, "Web push pusher is backing off, dropping notification");
return Ok(());
}
let prio = if data.format.as_ref() == Some(&PushFormat::EventIdOnly) {
NotificationPriority::Low
} else {
notify.prio.clone()
};
let payload = build_payload(&notify, &data.data)?;
self.deliver_webpush(user, pusher, data, payload, prio)
.await
}
#[tracing::instrument(skip(self, pusher, data, payload), level = "debug")]
/// Encrypts and sends a Web Push request, retrying transient failures and
/// deleting rejected subscriptions.
async fn deliver_webpush(
&self,
user: &UserId,
pusher: &Pusher,
data: &WebPushPusherData,
payload: Vec<u8>,
prio: NotificationPriority,
) -> Result {
let endpoint = self.validate_push_url(&data.url, WEBPUSH_SCHEMES)?;
let authorization = self.vapid.authorization(&endpoint)?;
let pushkey = pusher.ids.pushkey.as_str();
let mut attempt = 1_u32;
loop {
match self
.attempt_webpush(user, pusher, data, &payload, &authorization, &prio)
.await?
{
| Attempt::Delivered => {
self.db.senderkey_webpushbackoff.del((user, pushkey));
return Ok(());
},
| Attempt::Rejected => {
self.delete_pusher(user, pushkey).await;
return Ok(());
},
| Attempt::Failed(retry_after) => {
if attempt >= DELIVERY_ATTEMPTS {
self.back_off_webpush(user, pushkey, retry_after).await;
return Err!(BadServerResponse(warn!(
%user, %pushkey,
"Push service did not accept the notification after {attempt} attempts"
)));
}
let delay = retry_after
.unwrap_or_else(|| RETRY_BASE.saturating_mul(attempt))
.min(RETRY_MAX);
sleep(delay).await;
attempt = attempt.saturating_add(1);
},
}
}
}
/// The message is re-encrypted per attempt, since each needs its own
/// ephemeral key.
async fn attempt_webpush(
&self,
user: &UserId,
pusher: &Pusher,
data: &WebPushPusherData,
payload: &[u8],
authorization: &str,
prio: &NotificationPriority,
) -> Result<Attempt> {
let pushkey = pusher.ids.pushkey.as_str();
let mut request = WebPushBuilder::new(
data.url
.parse()
.map_err(|e| err!(Request(InvalidParam("Pusher URL is not a valid URI: {e}"))))?,
decode_pushkey(pushkey)?,
decode_auth(&data.auth)?,
)
.with_valid_duration(TTL)
.build(payload.to_vec())
.map_err(|e| err!("Failed to encrypt web push message: {e}"))?;
let headers = request.headers_mut();
headers.insert(http::header::AUTHORIZATION, authorization.parse()?);
headers.insert(
"urgency",
if *prio == NotificationPriority::High {
"high"
} else {
"normal"
}
.parse()?,
);
let response = match self
.execute_pusher_request(
&self.services.client.webpush,
reqwest::Request::try_from(request)?,
)
.await
{
| Ok(response) => response,
| Err(e) => {
debug_warn!(%user, %pushkey, "Could not reach the push service: {e}");
return Ok(Attempt::Failed(None));
},
};
let status = response.status();
if status.is_success() {
return Ok(Attempt::Delivered);
}
if matches!(status, StatusCode::FORBIDDEN | StatusCode::NOT_FOUND | StatusCode::GONE) {
warn!(
%user, %pushkey,
"Push service rejected the subscription ({status}), deleting pusher"
);
return Ok(Attempt::Rejected);
}
if status.is_redirection() {
warn!(%user, %pushkey, "Push service redirected ({status}); not following it");
}
debug_warn!(%user, %pushkey, "Push service returned unsuccessful HTTP response: {status}");
Ok(Attempt::Failed(
(status == StatusCode::TOO_MANY_REQUESTS)
.then(|| retry_after(response.headers()))
.flatten(),
))
}
async fn back_off_webpush(
&self,
user: &UserId,
pushkey: &str,
retry_after: Option<Duration>,
) {
let key = (user, pushkey);
let mut backoff: WebPushBackoff = self
.db
.senderkey_webpushbackoff
.qry(&key)
.await
.deserialized()
.unwrap_or_default();
backoff.failure_count = backoff.failure_count.saturating_add(1);
let delay = retry_after.map_or_else(
|| {
BACKOFF_BASE
.saturating_mul(1_u32 << backoff.failure_count.min(10))
.min(BACKOFF_MAX)
},
|retry_after| retry_after.min(BACKOFF_MAX),
);
backoff.next_attempt_at = utils::millis_since_unix_epoch()
.saturating_add(delay.as_millis().try_into().unwrap_or(u64::MAX));
self.db.senderkey_webpushbackoff.put(key, Json(&backoff));
}
}
/// RFC 9110 allows `Retry-After` to be either a delay in seconds or an HTTP
/// date.
fn retry_after(headers: &http::HeaderMap) -> Option<Duration> {
let value = headers.get(http::header::RETRY_AFTER)?.to_str().ok()?;
if let Ok(secs) = value.parse::<u64>() {
return Some(Duration::from_secs(secs));
}
httpdate::parse_http_date(value)
.ok()?
.duration_since(SystemTime::now())
.ok()
}
+97
View File
@@ -0,0 +1,97 @@
//! VAPID key management and authorization-header generation for Web Push.
use std::{collections::HashMap, sync::Arc};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use conduwuit_core::{Result, SyncRwLock, debug_info, err, utils};
use conduwuit_database::Database;
use p256::{
SecretKey,
ecdsa::{Signature, SigningKey, signature::Signer},
elliptic_curve::sec1::ToEncodedPoint,
};
use serde_json::json;
const VAPID_TTL_SECS: u64 = 12 * 60 * 60;
const VAPID_REFRESH_MARGIN_SECS: u64 = 60;
/// The server's VAPID keypair and cached authorization headers.
pub(crate) struct Vapid {
secret: SecretKey,
public_key: String,
contact: String,
authorizations: SyncRwLock<HashMap<String, (String, u64)>>,
}
impl Vapid {
/// Loads the persistent VAPID keypair, generating one on first use.
pub(crate) fn init(db: &Arc<Database>, contact: String) -> Result<Self> {
let secret = match db["global"].get_blocking(b"vapid_secret_key") {
| Ok(ref val) => SecretKey::from_slice(val).map_err(|e| {
err!(Database(error!(
"Stored VAPID key is invalid: {e}. Refusing to replace it, as that would \
invalidate every existing web push subscription."
)))
})?,
| Err(e) if !e.is_not_found() => return Err(e),
| Err(_) => {
let secret = loop {
if let Ok(secret) = SecretKey::from_slice(&rand::random::<[u8; 32]>()) {
break secret;
}
};
db["global"].raw_put(b"vapid_secret_key", &*secret.to_bytes());
debug_info!("Generated new VAPID keypair for web push");
secret
},
};
let public_key =
URL_SAFE_NO_PAD.encode(secret.public_key().to_encoded_point(false).as_bytes());
Ok(Self {
secret,
public_key,
contact,
authorizations: SyncRwLock::new(HashMap::new()),
})
}
pub(super) fn public_key(&self) -> &str { &self.public_key }
/// Returns a cached or freshly signed VAPID authorization for an endpoint.
pub(super) fn authorization(&self, endpoint: &url::Url) -> Result<String> {
let audience = endpoint.origin().ascii_serialization();
let now = utils::millis_since_unix_epoch().saturating_div(1000);
if let Some((header, expires_at)) = self.authorizations.read().get(&audience)
&& now.saturating_add(VAPID_REFRESH_MARGIN_SECS) < *expires_at
{
return Ok(header.clone());
}
let expires_at = now.saturating_add(VAPID_TTL_SECS);
let header = self.sign(&audience, expires_at)?;
let mut authorizations = self.authorizations.write();
authorizations.retain(|_, (_, expires_at)| now < *expires_at);
authorizations.insert(audience, (header.clone(), expires_at));
Ok(header)
}
fn sign(&self, audience: &str, expires_at: u64) -> Result<String> {
let header =
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json!({ "typ": "JWT", "alg": "ES256" }))?);
let claims = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json!({
"aud": audience,
"exp": expires_at,
"sub": self.contact,
}))?);
let signing_input = format!("{header}.{claims}");
let signature: Signature = SigningKey::from(&self.secret).sign(signing_input.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(signature.to_bytes());
Ok(format!("vapid t={signing_input}.{signature},k={}", self.public_key))
}
}