feat: Enhance invite security checks & do away with stripped state

This commit is contained in:
timedout
2025-12-23 19:50:37 +00:00
parent 1237e60aaf
commit 04980b3ee7
7 changed files with 281 additions and 166 deletions
Generated
+11 -11
View File
@@ -4063,7 +4063,7 @@ checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3"
[[package]] [[package]]
name = "ruma" name = "ruma"
version = "0.10.1" version = "0.10.1"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=27abe0dcd33fd4056efc94bab3582646b31b6ce9#27abe0dcd33fd4056efc94bab3582646b31b6ce9" source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=e60876b6ff2d207a46aa8910a02474268bac8592#e60876b6ff2d207a46aa8910a02474268bac8592"
dependencies = [ dependencies = [
"assign", "assign",
"js_int", "js_int",
@@ -4083,7 +4083,7 @@ dependencies = [
[[package]] [[package]]
name = "ruma-appservice-api" name = "ruma-appservice-api"
version = "0.10.0" version = "0.10.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=27abe0dcd33fd4056efc94bab3582646b31b6ce9#27abe0dcd33fd4056efc94bab3582646b31b6ce9" source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=e60876b6ff2d207a46aa8910a02474268bac8592#e60876b6ff2d207a46aa8910a02474268bac8592"
dependencies = [ dependencies = [
"js_int", "js_int",
"ruma-common", "ruma-common",
@@ -4095,7 +4095,7 @@ dependencies = [
[[package]] [[package]]
name = "ruma-client-api" name = "ruma-client-api"
version = "0.18.0" version = "0.18.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=27abe0dcd33fd4056efc94bab3582646b31b6ce9#27abe0dcd33fd4056efc94bab3582646b31b6ce9" source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=e60876b6ff2d207a46aa8910a02474268bac8592#e60876b6ff2d207a46aa8910a02474268bac8592"
dependencies = [ dependencies = [
"as_variant", "as_variant",
"assign", "assign",
@@ -4118,7 +4118,7 @@ dependencies = [
[[package]] [[package]]
name = "ruma-common" name = "ruma-common"
version = "0.13.0" version = "0.13.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=27abe0dcd33fd4056efc94bab3582646b31b6ce9#27abe0dcd33fd4056efc94bab3582646b31b6ce9" source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=e60876b6ff2d207a46aa8910a02474268bac8592#e60876b6ff2d207a46aa8910a02474268bac8592"
dependencies = [ dependencies = [
"as_variant", "as_variant",
"base64 0.22.1", "base64 0.22.1",
@@ -4150,7 +4150,7 @@ dependencies = [
[[package]] [[package]]
name = "ruma-events" name = "ruma-events"
version = "0.28.1" version = "0.28.1"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=27abe0dcd33fd4056efc94bab3582646b31b6ce9#27abe0dcd33fd4056efc94bab3582646b31b6ce9" source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=e60876b6ff2d207a46aa8910a02474268bac8592#e60876b6ff2d207a46aa8910a02474268bac8592"
dependencies = [ dependencies = [
"as_variant", "as_variant",
"indexmap", "indexmap",
@@ -4175,7 +4175,7 @@ dependencies = [
[[package]] [[package]]
name = "ruma-federation-api" name = "ruma-federation-api"
version = "0.9.0" version = "0.9.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=27abe0dcd33fd4056efc94bab3582646b31b6ce9#27abe0dcd33fd4056efc94bab3582646b31b6ce9" source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=e60876b6ff2d207a46aa8910a02474268bac8592#e60876b6ff2d207a46aa8910a02474268bac8592"
dependencies = [ dependencies = [
"bytes", "bytes",
"headers", "headers",
@@ -4197,7 +4197,7 @@ dependencies = [
[[package]] [[package]]
name = "ruma-identifiers-validation" name = "ruma-identifiers-validation"
version = "0.9.5" version = "0.9.5"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=27abe0dcd33fd4056efc94bab3582646b31b6ce9#27abe0dcd33fd4056efc94bab3582646b31b6ce9" source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=e60876b6ff2d207a46aa8910a02474268bac8592#e60876b6ff2d207a46aa8910a02474268bac8592"
dependencies = [ dependencies = [
"js_int", "js_int",
"thiserror 2.0.17", "thiserror 2.0.17",
@@ -4206,7 +4206,7 @@ dependencies = [
[[package]] [[package]]
name = "ruma-identity-service-api" name = "ruma-identity-service-api"
version = "0.9.0" version = "0.9.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=27abe0dcd33fd4056efc94bab3582646b31b6ce9#27abe0dcd33fd4056efc94bab3582646b31b6ce9" source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=e60876b6ff2d207a46aa8910a02474268bac8592#e60876b6ff2d207a46aa8910a02474268bac8592"
dependencies = [ dependencies = [
"js_int", "js_int",
"ruma-common", "ruma-common",
@@ -4216,7 +4216,7 @@ dependencies = [
[[package]] [[package]]
name = "ruma-macros" name = "ruma-macros"
version = "0.13.0" version = "0.13.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=27abe0dcd33fd4056efc94bab3582646b31b6ce9#27abe0dcd33fd4056efc94bab3582646b31b6ce9" source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=e60876b6ff2d207a46aa8910a02474268bac8592#e60876b6ff2d207a46aa8910a02474268bac8592"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"proc-macro-crate", "proc-macro-crate",
@@ -4231,7 +4231,7 @@ dependencies = [
[[package]] [[package]]
name = "ruma-push-gateway-api" name = "ruma-push-gateway-api"
version = "0.9.0" version = "0.9.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=27abe0dcd33fd4056efc94bab3582646b31b6ce9#27abe0dcd33fd4056efc94bab3582646b31b6ce9" source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=e60876b6ff2d207a46aa8910a02474268bac8592#e60876b6ff2d207a46aa8910a02474268bac8592"
dependencies = [ dependencies = [
"js_int", "js_int",
"ruma-common", "ruma-common",
@@ -4243,7 +4243,7 @@ dependencies = [
[[package]] [[package]]
name = "ruma-signatures" name = "ruma-signatures"
version = "0.15.0" version = "0.15.0"
source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=27abe0dcd33fd4056efc94bab3582646b31b6ce9#27abe0dcd33fd4056efc94bab3582646b31b6ce9" source = "git+https://forgejo.ellis.link/continuwuation/ruwuma?rev=e60876b6ff2d207a46aa8910a02474268bac8592#e60876b6ff2d207a46aa8910a02474268bac8592"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"ed25519-dalek", "ed25519-dalek",
+1 -1
View File
@@ -351,7 +351,7 @@ version = "0.1.2"
# Used for matrix spec type definitions and helpers # Used for matrix spec type definitions and helpers
[workspace.dependencies.ruma] [workspace.dependencies.ruma]
git = "https://forgejo.ellis.link/continuwuation/ruwuma" git = "https://forgejo.ellis.link/continuwuation/ruwuma"
rev = "27abe0dcd33fd4056efc94bab3582646b31b6ce9" rev = "e60876b6ff2d207a46aa8910a02474268bac8592"
features = [ features = [
"compat", "compat",
"rand", "rand",
+1 -1
View File
@@ -146,7 +146,7 @@ pub(crate) async fn invite_helper(
) )
.await?; .await?;
let invite_room_state = services.rooms.state.summary_stripped(&pdu, room_id).await; let invite_room_state = services.rooms.state.summary(&pdu, room_id).await;
drop(state_lock); drop(state_lock);
+186 -76
View File
@@ -1,21 +1,172 @@
use std::collections::HashMap;
use axum::extract::State; use axum::extract::State;
use axum_client_ip::InsecureClientIp; use axum_client_ip::InsecureClientIp;
use base64::{Engine as _, engine::general_purpose}; use base64::{Engine as _, engine::general_purpose};
use conduwuit::{ use conduwuit::{
Err, Error, PduEvent, Result, err, Err, Error, EventTypeExt, PduEvent, Result, RoomVersion, err,
matrix::{Event, event::gen_event_id}, matrix::{Event, StateKey, event::gen_event_id},
utils::{self, hash::sha256}, utils::{self, hash::sha256},
warn, warn,
}; };
use ruma::{ use ruma::{
CanonicalJsonValue, OwnedUserId, UserId, CanonicalJsonValue, OwnedUserId, RoomId, RoomVersionId, ServerName, UserId,
api::{client::error::ErrorKind, federation::membership::create_invite}, api::{client::error::ErrorKind, federation::membership::create_invite},
events::room::member::{MembershipState, RoomMemberEventContent}, events::{
serde::JsonObject, AnyStateEvent, StateEventType,
room::{
create::RoomCreateEventContent,
member::{MembershipState, RoomMemberEventContent},
},
},
serde::Raw,
}; };
use service::{Services, rooms::timeline::pdu_fits};
use crate::Ruma; use crate::Ruma;
/// Ensures that the state received from the invite endpoint is sane, correct,
/// and complies with the room version's requirements.
async fn check_invite_state(
services: &Services,
stripped_state: &Vec<Raw<AnyStateEvent>>,
room_id: &RoomId,
room_version_id: &RoomVersionId,
) -> Result<()> {
let room_version = RoomVersion::new(room_version_id).map_err(|e| {
err!(Request(UnsupportedRoomVersion("Invalid room version provided: {e}")))
})?;
let mut room_state: HashMap<(StateEventType, StateKey), PduEvent> = HashMap::new();
// Build the room state from the provided state events,
// ensuring that there's no duplicates. We need to check that m.room.create is
// present and lines up with the other things we've been told, and then verify
// any signatures present to ensure this isn't forged.
for raw_event in stripped_state {
let event = raw_event
.deserialize_as::<PduEvent>()
.map_err(|e| err!(Request(InvalidParam("Invalid state event: {e}"))))?;
if event.state_key().is_none() {
return Err!(Request(InvalidParam("State event missing event type.")));
}
let key = event
.event_type()
.with_state_key(event.state_key().unwrap());
if room_state.contains_key(&key) {
return Err!(Request(InvalidParam("Duplicate state event found for {key:?}")));
}
// verify the event
let canonical = utils::to_canonical_object(raw_event)?;
if !pdu_fits(&mut canonical.clone()) {
return Err!(Request(InvalidParam("An invite state event PDU is too large")));
}
services
.server_keys
.verify_event(&canonical, Some(room_version_id))
.await
.map_err(|e| err!(Request(InvalidParam("Signature failed verification: {e}"))))?;
// Ensure all events are in the same room
if event.room_id_or_hash() != room_id {
return Err!(Request(InvalidParam(
"State event room ID for {} does not match the expected room ID {}.",
event.event_id,
room_id,
)));
}
room_state.insert(key, event);
}
// verify m.room.create is present, has a matching room ID, and a matching room
// version.
let create_event = room_state
.get(&(StateEventType::RoomCreate, "".into()))
.ok_or_else(|| err!(Request(MissingParam("Missing m.room.create in stripped state."))))?;
let create_event_content: RoomCreateEventContent = create_event
.get_content()
.map_err(|e| err!(Request(InvalidParam("Invalid m.room.create content: {e}"))))?;
// Room v12 removed room IDs over federation, so we'll need to see if the event
// ID matches the room ID instead.
if room_version.room_ids_as_hashes {
let given_room_id = create_event.event_id().as_str().replace('$', "!");
if given_room_id != room_id.as_str() {
return Err!(Request(InvalidParam(
"m.room.create event ID does not match the room ID."
)));
}
} else if create_event.room_id().unwrap() != room_id {
return Err!(Request(InvalidParam("m.room.create room ID does not match the room ID.")));
}
// Make sure the room version matches
if &create_event_content.room_version != room_version_id {
return Err!(Request(InvalidParam(
"m.room.create room version does not match the given room version."
)));
}
// Looks solid
Ok(())
}
/// Ensures that the invite event received from the invite endpoint is sane,
/// correct, and complies with the room version's requirements.
/// Returns the invited user ID on success.
async fn check_invite_event(
services: &Services,
invite_event: &PduEvent,
origin: &ServerName,
room_id: &RoomId,
room_version_id: &RoomVersionId,
) -> Result<OwnedUserId> {
// Check: The event sender is not a user ID on the origin server.
if invite_event.sender.server_name() != origin {
return Err!(Request(InvalidParam(
"Invite event sender's server does not match the origin server."
)));
}
// Check: The `state_key` is not a user ID on the receiving server.
let state_key: &UserId = invite_event
.state_key()
.ok_or_else(|| err!(Request(MissingParam("Invite event missing state_key."))))?
.try_into()
.map_err(|e| err!(Request(InvalidParam("Invalid state_key property: {e}"))))?;
if !services.globals.server_is_ours(state_key.server_name()) {
return Err!(Request(InvalidParam(
"Invite event state_key does not belong to this homeserver."
)));
}
// Check: The event's room ID matches the expected room ID.
if let Some(evt_room_id) = invite_event.room_id() {
if evt_room_id != room_id {
return Err!(Request(InvalidParam(
"Invite event room ID does not match the expected room ID."
)));
}
} else {
return Err!(Request(MissingParam("Invite event missing room ID.")));
}
// Check: the membership really is "invite"
let content = invite_event.get_content::<RoomMemberEventContent>()?;
if content.membership != MembershipState::Invite {
return Err!(Request(InvalidParam("Invite event is not a membership invite.")));
}
// Check: signature is valid
services
.server_keys
.verify_event(&utils::to_canonical_object(invite_event)?, Some(room_version_id))
.await
.map_err(|e| {
err!(Request(InvalidParam("Invite event signature failed verification: {e}")))
})?;
Ok(state_key.to_owned())
}
/// # `PUT /_matrix/federation/v2/invite/{roomId}/{eventId}` /// # `PUT /_matrix/federation/v2/invite/{roomId}/{eventId}`
/// ///
/// Invites a remote user to a room. /// Invites a remote user to a room.
@@ -61,57 +212,39 @@ pub(crate) async fn create_invite_route(
let mut signed_event = utils::to_canonical_object(&body.event) let mut signed_event = utils::to_canonical_object(&body.event)
.map_err(|_| err!(Request(InvalidParam("Invite event is invalid."))))?; .map_err(|_| err!(Request(InvalidParam("Invite event is invalid."))))?;
// Ensure this is a membership event // We need to hash and sign the event before we can generate the event ID.
if signed_event // It is important that this signed event does not get sent back to the caller
.get("type") // until we've verified this isn't incorrect.
.expect("event must have a type") services
.as_str() .server_keys
.expect("type must be a string") .hash_and_sign_event(&mut signed_event, &body.room_version)
!= "m.room.member" .map_err(|e| err!(Request(InvalidParam("Failed to sign event: {e}"))))?;
{ let event_id = gen_event_id(&signed_event.clone(), &body.room_version)?;
return Err!(Request(BadJson( if event_id != body.event_id {
"Not allowed to send non-membership event to invite endpoint." return Err!(Request(InvalidParam(
"Event ID does not match the generated event ID ({} vs {}).",
event_id,
body.event_id,
))); )));
} }
let content: RoomMemberEventContent = serde_json::from_value( let pdu = PduEvent::from_id_val(&event_id, signed_event.clone())
signed_event .map_err(|e| err!(Request(InvalidParam("Invite event is invalid: {e}"))))?;
.get("content")
.ok_or_else(|| err!(Request(BadJson("Event missing content property"))))?
.clone()
.into(),
)
.map_err(|e| err!(Request(BadJson(warn!("Event content is empty or invalid: {e}")))))?;
// Ensure this is an invite membership event // Check the invite event is valid.
if content.membership != MembershipState::Invite { let recipient_user =
return Err!(Request(BadJson( check_invite_event(&services, &pdu, body.origin(), &body.room_id, &body.room_version)
"Not allowed to send a non-invite membership event to invite endpoint." .await?;
)));
}
// Ensure the sending user isn't a lying bozo // Make sure the room isn't banned and we allow invites
let sender_server = signed_event if services.config.block_non_admin_invites && !services.users.is_admin(&recipient_user).await
.get("sender")
.try_into()
.map(UserId::server_name)
.map_err(|e| err!(Request(InvalidParam("Invalid sender property: {e}"))))?;
if sender_server != body.origin() {
return Err!(Request(Forbidden("Sender's server does not match the origin server.",)));
}
// Ensure the target user belongs to this server
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(recipient_user.server_name())
{ {
return Err!(Request(InvalidParam("User does not belong to this homeserver."))); return Err!(Request(Forbidden("This server does not allow room invites.")));
}
if services.rooms.metadata.is_banned(&body.room_id).await
&& !services.users.is_admin(&recipient_user).await
{
return Err!(Request(Forbidden("This room is banned on this homeserver.")));
} }
// Make sure we're not ACL'ed from their room. // Make sure we're not ACL'ed from their room.
@@ -121,13 +254,9 @@ pub(crate) async fn create_invite_route(
.acl_check(recipient_user.server_name(), &body.room_id) .acl_check(recipient_user.server_name(), &body.room_id)
.await?; .await?;
services // And check the invite state is valid.
.server_keys check_invite_state(&services, &body.invite_room_state, &body.room_id, &body.room_version)
.hash_and_sign_event(&mut signed_event, &body.room_version) .await?;
.map_err(|e| err!(Request(InvalidParam("Failed to sign event: {e}"))))?;
// Generate event id
let event_id = gen_event_id(&signed_event, &body.room_version)?;
// Add event_id back // Add event_id back
signed_event.insert("event_id".to_owned(), CanonicalJsonValue::String(event_id.to_string())); signed_event.insert("event_id".to_owned(), CanonicalJsonValue::String(event_id.to_string()));
@@ -137,27 +266,8 @@ pub(crate) async fn create_invite_route(
.try_into() .try_into()
.map_err(|e| err!(Request(InvalidParam("Invalid sender property: {e}"))))?; .map_err(|e| err!(Request(InvalidParam("Invalid sender property: {e}"))))?;
if services.rooms.metadata.is_banned(&body.room_id).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(&recipient_user).await
{
return Err!(Request(Forbidden("This server does not allow room invites.")));
}
let mut invite_state = body.invite_room_state.clone(); let mut invite_state = body.invite_room_state.clone();
let mut event: JsonObject = serde_json::from_str(body.event.get())
.map_err(|e| err!(Request(BadJson("Invalid invite event PDU: {e}"))))?;
event.insert("event_id".to_owned(), "$placeholder".into());
let pdu: PduEvent = serde_json::from_value(event.into())
.map_err(|e| err!(Request(BadJson("Invalid invite event PDU: {e}"))))?;
invite_state.push(pdu.to_format()); invite_state.push(pdu.to_format());
// If we are active in the room, the remote server will notify us about the // If we are active in the room, the remote server will notify us about the
+1 -5
View File
@@ -175,11 +175,7 @@ pub(crate) async fn create_knock_event_v1_route(
.send_pdu_room(&body.room_id, &pdu_id) .send_pdu_room(&body.room_id, &pdu_id)
.await?; .await?;
let knock_room_state = services let knock_room_state = services.rooms.state.summary(&pdu, &body.room_id).await;
.rooms
.state
.summary_stripped(&pdu, &body.room_id)
.await;
Ok(send_knock::v1::Response { knock_room_state }) Ok(send_knock::v1::Response { knock_room_state })
} }
+16 -7
View File
@@ -19,8 +19,7 @@ use futures::{
use ruma::{ use ruma::{
EventId, OwnedEventId, OwnedRoomId, RoomId, RoomVersionId, UserId, EventId, OwnedEventId, OwnedRoomId, RoomId, RoomVersionId, UserId,
events::{ events::{
AnyStrippedStateEvent, StateEventType, TimelineEventType, AnyStateEvent, StateEventType, TimelineEventType, room::create::RoomCreateEventContent,
room::create::RoomCreateEventContent,
}, },
serde::Raw, serde::Raw,
}; };
@@ -307,12 +306,22 @@ impl Service {
} }
} }
/// Get a summary of the room state for invites and knock responses.
///
/// This used to return stripped state, but now returns complete events.
///
/// Returns:
///
/// - m.room.create
/// - m.room.join_rules
/// - m.room.canonical_alias
/// - m.room.name
/// - m.room.avatar
/// - m.room.member (of the event sender)
/// - m.room.encryption
/// - m.room.topic
#[tracing::instrument(skip_all, level = "debug")] #[tracing::instrument(skip_all, level = "debug")]
pub async fn summary_stripped<'a, E>( pub async fn summary<'a, E>(&self, event: &'a E, room_id: &RoomId) -> Vec<Raw<AnyStateEvent>>
&self,
event: &'a E,
room_id: &RoomId,
) -> Vec<Raw<AnyStrippedStateEvent>>
where where
E: Event + Send + Sync, E: Event + Send + Sync,
&'a E: Event + Send, &'a E: Event + Send,
+4 -4
View File
@@ -1,13 +1,13 @@
use std::collections::HashSet; use std::collections::HashSet;
use conduwuit::{Err, Event, Pdu, Result, implement, is_not_empty, utils::ReadyExt, warn}; use conduwuit::{Err, Event, Pdu, Result, implement, is_not_empty, utils::ReadyExt};
use database::{Json, serialize_key}; use database::{Json, serialize_key};
use futures::StreamExt; use futures::StreamExt;
use ruma::{ use ruma::{
OwnedServerName, RoomId, UserId, OwnedServerName, RoomId, UserId,
events::{ events::{
AnyStrippedStateEvent, GlobalAccountDataEventType, RoomAccountDataEventType, AnyStateEvent, AnyStrippedStateEvent, GlobalAccountDataEventType,
StateEventType, RoomAccountDataEventType, StateEventType,
direct::DirectEvent, direct::DirectEvent,
invite_permission_config::FilterLevel, invite_permission_config::FilterLevel,
room::{ room::{
@@ -334,7 +334,7 @@ pub async fn mark_as_invited(
user_id: &UserId, user_id: &UserId,
room_id: &RoomId, room_id: &RoomId,
sender_user: &UserId, sender_user: &UserId,
last_state: Option<Vec<Raw<AnyStrippedStateEvent>>>, last_state: Option<Vec<Raw<AnyStateEvent>>>,
invite_via: Option<Vec<OwnedServerName>>, invite_via: Option<Vec<OwnedServerName>>,
) -> Result<()> { ) -> Result<()> {
// return an error for blocked invites. ignored invites aren't handled here // return an error for blocked invites. ignored invites aren't handled here