From 41fb65f3791fff25a2216e9c21735cca202aaaff Mon Sep 17 00:00:00 2001 From: Ginger Date: Wed, 10 Sep 2025 14:52:30 -0400 Subject: [PATCH] feat: Check msc4155 invite filters when processing invites --- src/api/client/membership/invite.rs | 80 ++++++++++++++----------- src/api/client/room/create.rs | 63 ++++++++++++------- src/api/server/invite.rs | 38 ++++++++---- src/service/rooms/state_cache/update.rs | 26 +++++--- src/service/users/mod.rs | 24 +++++++- 5 files changed, 154 insertions(+), 77 deletions(-) diff --git a/src/api/client/membership/invite.rs b/src/api/client/membership/invite.rs index 018fb774b..78cd4289f 100644 --- a/src/api/client/membership/invite.rs +++ b/src/api/client/membership/invite.rs @@ -8,7 +8,10 @@ use futures::{FutureExt, join}; use ruma::{ OwnedServerName, RoomId, UserId, api::{client::membership::invite_user, federation::membership::create_invite}, - events::room::member::{MembershipState, RoomMemberEventContent}, + events::{ + invite_permission_config::FilterLevel, + room::member::{MembershipState, RoomMemberEventContent}, + }, }; use service::Services; @@ -47,22 +50,26 @@ pub(crate) async fn invite_user_route( .await?; match &body.recipient { - | invite_user::v3::InvitationRecipient::UserId { user_id } => { - let sender_ignored_recipient = services.users.user_is_ignored(sender_user, user_id); - let recipient_ignored_by_sender = - services.users.user_is_ignored(user_id, sender_user); + | invite_user::v3::InvitationRecipient::UserId { user_id: recipient_user } => { + let sender_filter_level = services + .users + .invite_filter_level(recipient_user, sender_user); + let recipient_filter_level = services + .users + .invite_filter_level(sender_user, recipient_user); - let (sender_ignored_recipient, recipient_ignored_by_sender) = - join!(sender_ignored_recipient, recipient_ignored_by_sender); + let (sender_filter_level, recipient_filter_level) = + join!(sender_filter_level, recipient_filter_level); - if sender_ignored_recipient { + if !matches!(sender_filter_level, FilterLevel::Allow) { + // drop invites if the sender has the recipient filtered return Ok(invite_user::v3::Response {}); } if let Ok(target_user_membership) = services .rooms .state_accessor - .get_member(&body.room_id, user_id) + .get_member(&body.room_id, recipient_user) .await { if target_user_membership.membership == MembershipState::Ban { @@ -70,24 +77,25 @@ pub(crate) async fn invite_user_route( } } - if recipient_ignored_by_sender { - // silently drop the invite to the recipient if they've been ignored by the - // sender, pretend it worked - return Ok(invite_user::v3::Response {}); + match recipient_filter_level { + | FilterLevel::Allow => { + invite_helper( + &services, + sender_user, + recipient_user, + &body.room_id, + body.reason.clone(), + false, + ) + .boxed() + .await?; + + Ok(invite_user::v3::Response {}) + }, + | FilterLevel::Ignore => Ok(invite_user::v3::Response {}), + | FilterLevel::Block => + Err!(Request(InviteBlocked("{recipient_user} has blocked invites from you."))), } - - invite_helper( - &services, - sender_user, - user_id, - &body.room_id, - body.reason.clone(), - false, - ) - .boxed() - .await?; - - Ok(invite_user::v3::Response {}) }, | _ => { Err!(Request(NotFound("User not found."))) @@ -98,7 +106,7 @@ pub(crate) async fn invite_user_route( pub(crate) async fn invite_helper( services: &Services, sender_user: &UserId, - user_id: &UserId, + recipient_user: &UserId, room_id: &RoomId, reason: Option, is_direct: bool, @@ -111,12 +119,12 @@ pub(crate) async fn invite_helper( return Err!(Request(Forbidden("Invites are not allowed on this server."))); } - if !services.globals.user_is_local(user_id) { + if !services.globals.user_is_local(recipient_user) { let (pdu, pdu_json, invite_room_state) = { let state_lock = services.rooms.state.mutex.lock(room_id).await; let content = RoomMemberEventContent { - avatar_url: services.users.avatar_url(user_id).await.ok(), + avatar_url: services.users.avatar_url(recipient_user).await.ok(), is_direct: Some(is_direct), reason, ..RoomMemberEventContent::new(MembershipState::Invite) @@ -126,7 +134,7 @@ pub(crate) async fn invite_helper( .rooms .timeline .create_hash_and_sign_event( - PduBuilder::state(user_id.to_string(), &content), + PduBuilder::state(recipient_user.to_string(), &content), sender_user, room_id, &state_lock, @@ -144,7 +152,7 @@ pub(crate) async fn invite_helper( let response = services .sending - .send_federation_request(user_id.server_name(), create_invite::v2::Request { + .send_federation_request(recipient_user.server_name(), create_invite::v2::Request { room_id: room_id.to_owned(), event_id: (*pdu.event_id).to_owned(), room_version: room_version_id.clone(), @@ -173,7 +181,7 @@ pub(crate) async fn invite_helper( return Err!(Request(BadJson(warn!( %pdu.event_id, %event_id, "Server {} sent event with wrong event ID", - user_id.server_name() + recipient_user.server_name() )))); } @@ -213,9 +221,9 @@ pub(crate) async fn invite_helper( let state_lock = services.rooms.state.mutex.lock(room_id).await; let content = RoomMemberEventContent { - displayname: services.users.displayname(user_id).await.ok(), - avatar_url: services.users.avatar_url(user_id).await.ok(), - blurhash: services.users.blurhash(user_id).await.ok(), + displayname: services.users.displayname(recipient_user).await.ok(), + avatar_url: services.users.avatar_url(recipient_user).await.ok(), + blurhash: services.users.blurhash(recipient_user).await.ok(), is_direct: Some(is_direct), reason, ..RoomMemberEventContent::new(MembershipState::Invite) @@ -225,7 +233,7 @@ pub(crate) async fn invite_helper( .rooms .timeline .build_and_append_pdu( - PduBuilder::state(user_id.to_string(), &content), + PduBuilder::state(recipient_user.to_string(), &content), sender_user, room_id, &state_lock, diff --git a/src/api/client/room/create.rs b/src/api/client/room/create.rs index 238691d14..597213256 100644 --- a/src/api/client/room/create.rs +++ b/src/api/client/room/create.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use axum::extract::State; use conduwuit::{ @@ -13,6 +13,7 @@ use ruma::{ api::client::room::{self, create_room}, events::{ TimelineEventType, + invite_permission_config::FilterLevel, room::{ canonical_alias::RoomCanonicalAliasEventContent, create::RoomCreateEventContent, @@ -100,6 +101,38 @@ pub(crate) async fn create_room_route( return Err!(Request(Forbidden("Publishing rooms to the room directory is not allowed"))); } + + let mut invitees = BTreeSet::new(); + + for recipient_user in &body.invite { + if !matches!( + services + .users + .invite_filter_level(recipient_user, sender_user) + .await, + FilterLevel::Allow + ) { + // drop invites if the creator has them blocked + continue; + } + match services + .users + .invite_filter_level(sender_user, recipient_user) + .await + { + | FilterLevel::Allow => { + invitees.insert(recipient_user.clone()); + }, + | FilterLevel::Ignore => { + continue; + }, + | FilterLevel::Block => + return Err!(Request(InviteBlocked( + "{recipient_user} has blocked invites from you." + ))), + } + } + let _short_id = services .rooms .short @@ -219,26 +252,18 @@ pub(crate) async fn create_room_route( | _ => RoomPreset::PrivateChat, // Room visibility should not be custom }); - let mut users = BTreeMap::from_iter([(sender_user.to_owned(), int!(100))]); + let mut power_levels_to_grant = BTreeMap::from_iter([(sender_user.to_owned(), int!(100))]); if preset == RoomPreset::TrustedPrivateChat { - for invite in &body.invite { - if services.users.user_is_ignored(sender_user, invite).await { - continue; - } else if services.users.user_is_ignored(invite, sender_user).await { - // silently drop the invite to the recipient if they've been ignored by the - // sender, pretend it worked - continue; - } - - users.insert(invite.clone(), int!(100)); + for recipient_user in &invitees { + power_levels_to_grant.insert(recipient_user.clone(), int!(100)); } } let power_levels_content = default_power_levels_content( body.power_level_content_override.as_ref(), &body.visibility, - users, + power_levels_to_grant, )?; services @@ -399,17 +424,9 @@ pub(crate) async fn create_room_route( // 8. Events implied by invite (and TODO: invite_3pid) drop(state_lock); - for user_id in &body.invite { - if services.users.user_is_ignored(sender_user, user_id).await { - continue; - } else if services.users.user_is_ignored(user_id, sender_user).await { - // silently drop the invite to the recipient if they've been ignored by the - // sender, pretend it worked - continue; - } - + for recipient_user in &invitees { if let Err(e) = - invite_helper(&services, sender_user, user_id, &room_id, None, body.is_direct) + invite_helper(&services, sender_user, recipient_user, &room_id, None, body.is_direct) .boxed() .await { diff --git a/src/api/server/invite.rs b/src/api/server/invite.rs index 0a9b2e10d..ac71513a6 100644 --- a/src/api/server/invite.rs +++ b/src/api/server/invite.rs @@ -10,7 +10,10 @@ use conduwuit::{ use ruma::{ CanonicalJsonValue, OwnedUserId, UserId, api::{client::error::ErrorKind, federation::membership::create_invite}, - events::room::member::{MembershipState, RoomMemberEventContent}, + events::{ + invite_permission_config::FilterLevel, + room::member::{MembershipState, RoomMemberEventContent}, + }, serde::JsonObject, }; @@ -61,13 +64,16 @@ pub(crate) async fn create_invite_route( let mut signed_event = utils::to_canonical_object(&body.event) .map_err(|_| err!(Request(InvalidParam("Invite event is invalid."))))?; - let invited_user: OwnedUserId = signed_event + let recipient_user: OwnedUserId = signed_event .get("state_key") .try_into() .map(UserId::to_owned) .map_err(|e| err!(Request(InvalidParam("Invalid state_key property: {e}"))))?; - if !services.globals.server_is_ours(invited_user.server_name()) { + if !services + .globals + .server_is_ours(recipient_user.server_name()) + { return Err!(Request(InvalidParam("User does not belong to this homeserver."))); } @@ -75,7 +81,7 @@ pub(crate) async fn create_invite_route( services .rooms .event_handler - .acl_check(invited_user.server_name(), &body.room_id) + .acl_check(recipient_user.server_name(), &body.room_id) .await?; services @@ -89,21 +95,33 @@ pub(crate) async fn create_invite_route( // Add event_id back signed_event.insert("event_id".to_owned(), CanonicalJsonValue::String(event_id.to_string())); - let sender: &UserId = signed_event + let sender_user: &UserId = signed_event .get("sender") .try_into() .map_err(|e| err!(Request(InvalidParam("Invalid sender property: {e}"))))?; if services.rooms.metadata.is_banned(&body.room_id).await - && !services.users.is_admin(&invited_user).await + && !services.users.is_admin(&recipient_user).await { return Err!(Request(Forbidden("This room is banned on this homeserver."))); } - if services.config.block_non_admin_invites && !services.users.is_admin(&invited_user).await { + if services.config.block_non_admin_invites && !services.users.is_admin(&recipient_user).await + { return Err!(Request(Forbidden("This server does not allow room invites."))); } + let recipient_invite_filter_level = services + .users + .invite_filter_level(sender_user, &recipient_user) + .await; + + if matches!(recipient_invite_filter_level, FilterLevel::Block) { + return Err!(Request(InviteBlocked( + "{recipient_user} has blocked invites from {sender_user}." + ))); + } + let mut invite_state = body.invite_room_state.clone(); let mut event: JsonObject = serde_json::from_str(body.event.get()) @@ -131,9 +149,9 @@ pub(crate) async fn create_invite_route( .state_cache .update_membership( &body.room_id, - &invited_user, + &recipient_user, RoomMemberEventContent::new(MembershipState::Invite), - sender, + sender_user, Some(invite_state), body.via.clone(), true, @@ -141,7 +159,7 @@ pub(crate) async fn create_invite_route( .await?; for appservice in services.appservice.read().await.values() { - if appservice.is_user_match(&invited_user) { + if appservice.is_user_match(&recipient_user) { services .sending .send_appservice_request( diff --git a/src/service/rooms/state_cache/update.rs b/src/service/rooms/state_cache/update.rs index 86c1afe72..a1ff145d3 100644 --- a/src/service/rooms/state_cache/update.rs +++ b/src/service/rooms/state_cache/update.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; -use conduwuit::{Result, implement, is_not_empty, utils::ReadyExt, warn}; +use conduwuit::{Err, Result, implement, is_not_empty, utils::ReadyExt, warn}; use database::{Json, serialize_key}; use futures::StreamExt; use ruma::{ @@ -9,6 +9,7 @@ use ruma::{ AnyStrippedStateEvent, AnySyncStateEvent, GlobalAccountDataEventType, RoomAccountDataEventType, StateEventType, direct::DirectEvent, + invite_permission_config::FilterLevel, room::{ create::RoomCreateEventContent, member::{MembershipState, RoomMemberEventContent}, @@ -121,13 +122,24 @@ pub async fn update_membership( self.mark_as_joined(user_id, room_id); }, | MembershipState::Invite => { - // We want to know if the sender is ignored by the receiver - if self.services.users.user_is_ignored(sender, user_id).await { - return Ok(()); + match self + .services + .users + .invite_filter_level(sender, user_id) + .await + { + | FilterLevel::Allow => { + self.mark_as_invited(user_id, room_id, last_state, invite_via) + .await; + }, + | FilterLevel::Ignore => { + // do nothing + }, + | FilterLevel::Block => + return Err!(Request(InviteBlocked( + "{user_id} has blocked invites from you." + ))), } - - self.mark_as_invited(user_id, room_id, last_state, invite_via) - .await; }, | MembershipState::Leave | MembershipState::Ban => { self.mark_as_left(user_id, room_id); diff --git a/src/service/users/mod.rs b/src/service/users/mod.rs index 6ddd8d798..931f4b987 100644 --- a/src/service/users/mod.rs +++ b/src/service/users/mod.rs @@ -20,7 +20,9 @@ use ruma::{ api::client::{device::Device, error::ErrorKind, filter::FilterDefinition}, encryption::{CrossSigningKey, DeviceKeys, OneTimeKey}, events::{ - AnyToDeviceEvent, GlobalAccountDataEventType, ignored_user_list::IgnoredUserListEvent, + AnyToDeviceEvent, GlobalAccountDataEventType, + ignored_user_list::IgnoredUserListEvent, + invite_permission_config::{FilterLevel, InvitePermissionConfigEvent}, }, serde::Raw, }; @@ -139,6 +141,26 @@ impl Service { }) } + /// Returns the recipient's filter level for an invite from the sender. + pub async fn invite_filter_level( + &self, + sender_user: &UserId, + recipient_user: &UserId, + ) -> FilterLevel { + if self.user_is_ignored(sender_user, recipient_user).await { + FilterLevel::Ignore + } else { + self.services + .account_data + .get_global(recipient_user, GlobalAccountDataEventType::InvitePermissionConfig) + .await + .map(|config: InvitePermissionConfigEvent| { + config.content.user_filter_level(sender_user) + }) + .unwrap_or(FilterLevel::Allow) + } + } + /// Check if a user is an admin #[inline] pub async fn is_admin(&self, user_id: &UserId) -> bool {