From e6dd5abdfffd9e1d8f561ed8a0fc4304bc6b705e Mon Sep 17 00:00:00 2001 From: Jacob Taylor Date: Sat, 11 Jul 2026 06:36:22 -0700 Subject: [PATCH] fix: Merge nex/fix/incoming-federated-invites --- src/api/server/invite.rs | 386 +++++++++++++----- src/api/server/make_join.rs | 8 - src/api/server/make_knock.rs | 6 - src/api/server/make_leave.rs | 1 + src/api/server/send.rs | 16 +- src/api/server/send_join.rs | 179 +++----- src/api/server/send_knock.rs | 124 ++---- src/api/server/send_leave.rs | 135 ++---- src/api/server/utils.rs | 105 ++++- .../rooms/event_handler/handle_outlier_pdu.rs | 5 +- src/service/rooms/event_handler/pdu_checks.rs | 8 +- 11 files changed, 522 insertions(+), 451 deletions(-) diff --git a/src/api/server/invite.rs b/src/api/server/invite.rs index 9d2fab0e3..083c86d6d 100644 --- a/src/api/server/invite.rs +++ b/src/api/server/invite.rs @@ -1,23 +1,29 @@ +use std::collections::{HashMap, hash_map::Entry}; + use axum::extract::State; use axum_client_ip::ClientIp; use base64::{Engine as _, engine::general_purpose}; use conduwuit::{ - Err, Error, PduEvent, Result, err, error, - matrix::{Event, event::gen_event_id}, - utils::{self, hash::sha256}, + Err, Error, EventTypeExt, PduEvent, Result, err, error, + matrix::{Event, StateKey}, + result::FlatOk, + state_res, + utils::hash::sha256, warn, }; use ruma::{ - CanonicalJsonValue, UserId, + CanonicalJsonObject, CanonicalJsonValue, OwnedEventId, OwnedRoomId, OwnedUserId, ServerName, + UserId, api::{ error::{ErrorKind, IncompatibleRoomVersionErrorData}, federation::membership::{RawStrippedState, create_invite}, }, - events::room::member::{MembershipState, RoomMemberEventContent}, - serde::JsonObject, + events::{StateEventType, room::member::MembershipState}, + room_version_rules::RoomVersionRules, }; +use serde::Deserialize; -use crate::Ruma; +use crate::{Ruma, server::utils::validate_any_membership_event}; /// # `PUT /_matrix/federation/v2/invite/{roomId}/{eventId}` /// @@ -28,30 +34,16 @@ pub(crate) async fn create_invite_route( ClientIp(client): ClientIp, body: Ruma, ) -> Result { - // ACL check origin - services - .rooms - .event_handler - .acl_check(&body.identity, &body.room_id) - .await?; - if !services.server.supported_room_version(&body.room_version) { return Err(Error::BadRequest( ErrorKind::IncompatibleRoomVersion(IncompatibleRoomVersionErrorData::new( body.room_version.clone(), )), - "Server does not support this room version.", + "This server does not support that room version", )); } - let room_version_rules = body.room_version.rules().unwrap(); - if let Some(server) = body.room_id.server_name() { - if services.moderation.is_remote_server_forbidden(server) { - return Err!(Request(Forbidden("Server is banned on this homeserver."))); - } - } - if services .moderation .is_remote_server_forbidden(&body.identity) @@ -61,90 +53,54 @@ pub(crate) async fn create_invite_route( body.identity, body.room_id ); - return Err!(Request(Forbidden("Server is banned on this homeserver."))); + return Err!(Request(Forbidden("Federation denied with {}", body.identity))); } - let mut signed_event = utils::to_canonical_object(&body.event) - .map_err(|_| err!(Request(InvalidParam("Invite event is invalid."))))?; - - // Ensure this is a membership event - if signed_event - .get("type") - .expect("event must have a type") - .as_str() - .expect("type must be a string") - != "m.room.member" - { - return Err!(Request(BadJson( - "Not allowed to send non-membership event to invite endpoint." - ))); - } - - let content: RoomMemberEventContent = serde_json::from_value( - signed_event - .get("content") - .ok_or_else(|| err!(Request(BadJson("Event missing content property"))))? - .clone() - .into(), + // First, validate the invite room state, so we can compare with the create + // event. + let (create_event_id, state) = validate_invite_state( + &services, + &body.invite_room_state, + &room_version_rules, + body.room_id.clone(), ) - .map_err(|e| err!(Request(BadJson(warn!("Event content is empty or invalid: {e}")))))?; + .await?; + let create_event_json = state + .get(&StateEventType::RoomCreate.with_state_key("")) + .expect("must have create event in invite state by this point"); - // Ensure this is an invite membership event - if content.membership != MembershipState::Invite { - return Err!(Request(BadJson( - "Not allowed to send a non-invite membership event to invite endpoint." - ))); - } - - // Ensure the sending user isn't a lying bozo - let sender_user = signed_event + // We can now perform the banned remote server check with the create event. + // N.B. this checks the sender field, which is technically incorrect for rooms + // v10 and below. This usually isn't the case though so sue me + let creator = create_event_json .get("sender") .and_then(|v| v.as_str()) .map(UserId::parse) - .and_then(Result::ok) - .ok_or_else(|| err!(Request(InvalidParam("Invalid sender property"))))?; - - if sender_user.server_name() != body.identity { - return Err!(Request(Forbidden("Sender's server does not match the origin server.",))); - } - - // Ensure the target user belongs to this server - let recipient_user = signed_event - .get("state_key") - .and_then(|v| v.as_str()) - .map(UserId::parse) - .and_then(Result::ok) - .ok_or_else(|| err!(Request(InvalidParam("Invalid state_key property"))))?; - - if !services - .globals - .server_is_ours(recipient_user.server_name()) + .flat_ok() + .expect("must have valid sender in create event"); + if services + .moderation + .is_remote_server_forbidden(creator.server_name()) { - return Err!(Request(InvalidParam("User does not belong to this homeserver."))); + return Err!(Request(Forbidden("Server is banned on this homeserver."))); } - // Make sure we're not ACL'ed from their room. - services - .rooms - .event_handler - .acl_check(recipient_user.server_name(), &body.room_id) - .await?; - - services - .server_keys - .hash_and_sign_event(&mut signed_event, &room_version_rules) - .map_err(|e| err!(Request(InvalidParam("Failed to sign event: {e}"))))?; - - // Generate event id - let event_id = gen_event_id(&signed_event, &room_version_rules)?; - - // Add event_id back - signed_event.insert("event_id".to_owned(), CanonicalJsonValue::String(event_id.to_string())); + // And then we can validate the member event itself + let (mut signed_event, sender_user, recipient_user) = validate_invite_membership_event( + &services, + &body.event, + &room_version_rules, + &body.identity, + create_event_id.clone(), + body.room_id.clone(), + body.event_id.clone(), + ) + .await?; 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."))); + return Err!(Request(Forbidden("That room is banned on this homeserver."))); } if services.config.block_non_admin_invites && !services.users.is_admin(&recipient_user).await @@ -161,30 +117,57 @@ pub(crate) async fn create_invite_route( return Err!(Request(Forbidden("Invite rejected by antispam service."))); } + // If we're already in the room, ensure that neither the origin nor ourselves + // are ACL'd. + let resident = services + .rooms + .state_cache + .server_in_room(services.globals.server_name(), &body.room_id) + .await; + if resident { + services + .rooms + .event_handler + .acl_check(&body.identity, &body.room_id) + .await?; + services + .rooms + .event_handler + .acl_check(recipient_user.server_name(), &body.room_id) + .await + .map_err(|_| err!(Request(Forbidden("This server is ACL'd from that room"))))?; + } + + services + .server_keys + .hash_and_sign_event(&mut signed_event, &room_version_rules) + .map_err(|e| err!(Request(InvalidParam("Failed to sign event: {e}"))))?; + + // Add event_id back + signed_event + .insert("event_id".to_owned(), CanonicalJsonValue::String(body.event_id.to_string())); + 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(RawStrippedState::Pdu( - serde_json::value::to_raw_value(&pdu).expect("PDU was just created, it must be valid"), - )); + let pdu = PduEvent::from_id_val(&body.event_id, signed_event.clone()) + .expect("must be able to create PDU object"); + invite_state.push(RawStrippedState::Pdu(serde_json::value::to_raw_value(&signed_event)?)); // If we are active in the room, the remote server will notify us about the // join/invite through /send. If we are not in the room, we need to manually // record the invited state for client /sync through update_membership(), and // send the invite PDU to the relevant appservices. - if !services - .rooms - .state_cache - .server_in_room(services.globals.server_name(), &body.room_id) - .await - { + if !resident { + // We will start by recording the room's create event as an outlier. + // This will allow us to recognise it later in case the sender revokes the + // invite over federation later. We could store more state from the invite + // request, but we will get that during send_join anyway. + // This is safe to just add directly as an outlier as we already auth checked it + // during validation. + services + .rooms + .outlier + .add_pdu_outlier(&create_event_id, create_event_json); + services .rooms .state_cache @@ -240,3 +223,186 @@ pub(crate) async fn create_invite_route( .await, )) } + +/// Validates the *membership event* in the invite request, per the steps listed +/// under the invite endpoint's [spec]. +/// +/// Returns the validated JSON body, sender user ID, and recipient user ID. +/// +/// Since this function performs a PDU format check, the create event must be +/// known ahead of time. This implies validating the invite state before the +/// invite event itself. +/// +/// [spec]: https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv2inviteroomideventid +async fn validate_invite_membership_event( + services: &crate::State, + body: &serde_json::value::RawValue, + room_version_rules: &RoomVersionRules, + origin: &ServerName, + create_event_id: OwnedEventId, + room_id: OwnedRoomId, + event_id: OwnedEventId, +) -> Result<(CanonicalJsonObject, OwnedUserId, OwnedUserId)> { + let (pdu, target_membership, sender_user, recipient_user) = validate_any_membership_event( + services, + body, + room_version_rules, + create_event_id, + room_id, + event_id, + ) + .await?; + + // Ensure the sender belongs to the remote that is sending the invite + if sender_user.server_name() != origin { + return Err!(Request(Forbidden("Sender belongs to a different server"))); + } + + // Ensure the target user belongs to this server + if !services + .globals + .server_is_ours(recipient_user.server_name()) + { + return Err!(Request(InvalidParam("Recipient does not belong to this homeserver"))); + } + + if target_membership != MembershipState::Invite { + return Err!(Request(BadJson("Invalid membership (expected `invite`)"))); + } + + Ok((pdu, sender_user, recipient_user)) +} + +/// Validates the *invite state* of an invite request, per the steps listed +/// under the endpoint's [spec]. +/// +/// Returns the create event's event ID, and the partial state map. +/// +/// [spec]: https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv2inviteroomideventid +async fn validate_invite_state( + services: &crate::State, + invite_state: &[RawStrippedState], + room_version_rules: &RoomVersionRules, + room_id: OwnedRoomId, +) -> Result<(OwnedEventId, HashMap<(StateEventType, StateKey), CanonicalJsonObject>)> { + let mut invite_state_map: HashMap<(StateEventType, StateKey), _> = + HashMap::with_capacity(invite_state.len()); + let mut create_event_id: Option = None; + for (idx, invite_state_event) in invite_state.iter().cloned().enumerate() { + // Stripped state hasn't been sent over federation since v1.16. + let RawStrippedState::Pdu(raw_pdu) = invite_state_event else { + return Err!(Request(InvalidParam( + "PDU in invite state (index {idx}) violates the room event format" + ))); + }; + let (state_event_room_id, state_event_id, state_event_json) = services + .rooms + .event_handler + .parse_incoming_pdu(&raw_pdu) + .await + .map_err(|e| err!(Request(InvalidParam("Invalid PDU in invite state: {e}"))))?; + + if state_event_room_id != room_id { + return Err!(Request(InvalidParam( + "PDU in invite state ({state_event_id}) belongs to the wrong room" + ))); + } + + services + .server_keys + .verify_event(&state_event_json, room_version_rules) + .await + .map_err(|e| { + err!(Request(InvalidParam("Signature verification failed on invite event: {e}"))) + })?; + + let Some(state_key) = state_event_json.get("state_key").and_then(|k| k.as_str()) else { + return Err!(Request(InvalidParam( + "PDU in invite state ({state_event_id}) is not a state event" + ))); + }; + let Some(event_type) = state_event_json.get("event_type").and_then(|k| k.as_str()) else { + return Err!(Request(InvalidParam( + "PDU in invite state ({state_event_id}) is not an event?" + ))); + }; + + let key = StateEventType::from(event_type).with_state_key(state_key); + match invite_state_map.entry(key) { + | Entry::Occupied(entry) => + return Err!(Request(InvalidParam( + "Duplicate state events in invite state for state key: {:?}", + entry.key(), + ))), + | Entry::Vacant(entry) => { + if entry.key().0 == StateEventType::RoomCreate { + // Ensure this is a legal create event. + let pdu_event = + PduEvent::from_id_val(&state_event_id, state_event_json.clone()) + .expect("must be able to create pdu event from event json"); + validate_invite_create_event(&pdu_event, room_version_rules).await?; + create_event_id = Some(state_event_id); + } + entry.insert(state_event_json); + }, + } + } + let Some(create_event_id) = create_event_id else { + return Err!(Request(InvalidParam( + "Invite state does not contain the m.room.create event" + ))); + }; + invite_state_map.iter().try_for_each(|(key, event_json)| { + service::rooms::event_handler::Service::pdu_format_check_1( + event_json, + room_version_rules, + &create_event_id, + ) + .map_err(|e| { + err!(Request(InvalidParam( + "PDU in invite state for {key:?} violates the room event format: {e}" + ))) + }) + })?; + + Ok((create_event_id, invite_state_map)) +} + +#[derive(Deserialize)] +struct MFederate { + #[serde(rename = "m.federate")] + mfederate: Option, +} + +/// Validates that a create event is suitable for the invite, namely: +/// +/// 1. It passes auth checks (aka is valid) +/// 2. The room is federated (there's no point persisting unfederated rooms) +async fn validate_invite_create_event( + pdu: &PduEvent, + room_version_rules: &RoomVersionRules, +) -> Result { + if !state_res::auth_check( + room_version_rules, + pdu, + None, + |_, _| async { + unreachable!("No state should be fetched when processing a lone create event"); + }, + pdu, + ) + .await + .unwrap_or_default() + { + return Err!(Request(InvalidParam("m.room.create event fails auth check"))); + } + + let can_federate = pdu.get_content::()?.mfederate; + if !can_federate.unwrap_or(true) { + return Err!(Request(InvalidParam( + "Cannot receive invites to a room with m.federate=false" + ))); + } + + Ok(()) +} diff --git a/src/api/server/make_join.rs b/src/api/server/make_join.rs index 2da92752b..d9b7a38c8 100644 --- a/src/api/server/make_join.rs +++ b/src/api/server/make_join.rs @@ -75,14 +75,6 @@ pub(crate) async fn create_join_event_template_route( return Err!(Request(Forbidden("Server is banned on this homeserver."))); } - if let Some(server) = body.room_id.server_name() { - if services.moderation.is_remote_server_forbidden(server) { - return Err!(Request(Forbidden(warn!( - "Room ID server name {server} is banned on this homeserver." - )))); - } - } - let room_version = services.rooms.state.get_room_version(&body.room_id).await?; let room_version_rules = room_version.rules().unwrap(); if !body.ver.contains(&room_version) { diff --git a/src/api/server/make_knock.rs b/src/api/server/make_knock.rs index d6cadf02d..4c918daa8 100644 --- a/src/api/server/make_knock.rs +++ b/src/api/server/make_knock.rs @@ -58,12 +58,6 @@ pub(crate) async fn create_knock_event_template_route( return Err!(Request(Forbidden("Server is banned on this homeserver."))); } - if let Some(server) = body.room_id.server_name() { - if services.moderation.is_remote_server_forbidden(server) { - return Err!(Request(Forbidden("Server is banned on this homeserver."))); - } - } - let room_version = services.rooms.state.get_room_version(&body.room_id).await?; let room_version_rules = room_version.rules().unwrap(); diff --git a/src/api/server/make_leave.rs b/src/api/server/make_leave.rs index 168e235d7..1a472aa69 100644 --- a/src/api/server/make_leave.rs +++ b/src/api/server/make_leave.rs @@ -27,6 +27,7 @@ pub(crate) async fn create_leave_event_template_route( { info!( origin = body.identity.as_str(), + room_id = %body.room_id, "Refusing to serve make_leave for room we aren't participating in" ); return Err!(Request(NotFound("This server is not participating in that room."))); diff --git a/src/api/server/send.rs b/src/api/server/send.rs index cb3a8eeea..7b51ee014 100644 --- a/src/api/server/send.rs +++ b/src/api/server/send.rs @@ -296,12 +296,16 @@ async fn handle_room( // Try to sort PDUs by their dependencies, but fall back to arbitrary order on // failure (e.g., cycles). This is best-effort; proper ordering is the sender's // responsibility. - let sorted_event_ids = build_local_dag(&pdu_map, DagBuilderTree::PrevEvents) - .await - .unwrap_or_else(|e| { - debug_warn!("Failed to build local DAG for room {room_id}: {e}"); - pdu_map.keys().cloned().collect() - }); + let sorted_event_ids = if pdu_map.len() >= 2 { + build_local_dag(&pdu_map, DagBuilderTree::PrevEvents) + .await + .unwrap_or_else(|e| { + debug_warn!("Failed to build local DAG for room {room_id}: {e}"); + pdu_map.keys().cloned().collect() + }) + } else { + pdu_map.keys().cloned().collect() + }; let mut results = Vec::with_capacity(sorted_event_ids.len()); for event_id in sorted_event_ids { let value = pdu_map diff --git a/src/api/server/send_join.rs b/src/api/server/send_join.rs index b72c4c18a..0b8855239 100644 --- a/src/api/server/send_join.rs +++ b/src/api/server/send_join.rs @@ -2,26 +2,25 @@ use axum::extract::State; use conduwuit::{ - Err, Event, Result, at, debug, err, info, - matrix::event::gen_event_id_canonical_json, - trace, + Err, Event, Result, at, debug, err, info, trace, utils::stream::{BroadbandExt, IterStream, TryBroadbandExt}, warn, }; use conduwuit_service::Services; use futures::{FutureExt, StreamExt, TryStreamExt}; use ruma::{ - CanonicalJsonValue, OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, ServerName, + CanonicalJsonObject, CanonicalJsonValue, EventId, OwnedEventId, RoomId, ServerName, UserId, api::federation::membership::create_join_event, assign, events::{ - StateEventType, TimelineEventType, + TimelineEventType, room::member::{MembershipState, RoomMemberEventContent}, }, + room_version_rules::RoomVersionRules, }; -use serde_json::value::{RawValue as RawJsonValue, to_raw_value}; +use serde_json::value::to_raw_value; -use crate::Ruma; +use crate::{Ruma, server::utils::validate_any_membership_event}; /// helper method for /send_join v1 and v2 #[tracing::instrument(skip(services, pdu, omit_members), fields(room_id = room_id.as_str(), origin = origin.as_str()), level = "info")] @@ -29,7 +28,9 @@ async fn create_join_event( services: &Services, origin: &ServerName, room_id: &RoomId, - pdu: &RawJsonValue, + event_id: &EventId, + room_version_rules: &RoomVersionRules, + mut pdu: CanonicalJsonObject, omit_members: bool, ) -> Result { if !services.rooms.metadata.exists(room_id).await { @@ -43,6 +44,7 @@ async fn create_join_event( { info!( origin = origin.as_str(), + room_id = %room_id, "Refusing to serve send_join for room we aren't participating in" ); return Err!(Request(NotFound("This server is not participating in that room."))); @@ -64,94 +66,25 @@ async fn create_join_event( .await .map_err(|e| err!(Request(NotFound(error!("Room has no state: {e}")))))?; - // We do not add the event_id field to the pdu here because of signature and - // hashes checks - trace!("Getting room version"); - let room_version = services.rooms.state.get_room_version(room_id).await?; - let room_version_rules = room_version.rules().unwrap(); - - trace!("Generating event ID and converting to canonical json"); - let Ok((event_id, mut value)) = gen_event_id_canonical_json(pdu, &room_version_rules) else { - // Event could not be converted to canonical json - return Err!(Request(BadJson("Could not convert event to canonical json."))); - }; - - let event_room_id: OwnedRoomId = serde_json::from_value( - value - .get("room_id") - .ok_or_else(|| err!(Request(BadJson("Event missing room_id property."))))? - .clone() - .into(), - ) - .map_err(|e| err!(Request(BadJson(warn!("room_id field is not a valid room ID: {e}")))))?; - - if event_room_id != room_id { - return Err!(Request(BadJson("Event room_id does not match request path room ID."))); - } - - let event_type: StateEventType = serde_json::from_value( - value - .get("type") - .ok_or_else(|| err!(Request(BadJson("Event missing type property."))))? - .clone() - .into(), - ) - .map_err(|e| err!(Request(BadJson(warn!("Event has invalid state event type: {e}")))))?; - - if event_type != StateEventType::RoomMember { - return Err!(Request(BadJson( - "Not allowed to send non-membership state event to join endpoint." - ))); - } + let sender = pdu + .get("sender") + .and_then(|v| v.as_str()) + .map(UserId::parse) + .and_then(Result::ok) + .expect("sender was already validated"); let content: RoomMemberEventContent = serde_json::from_value( - value - .get("content") + pdu.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}")))))?; - if content.membership != MembershipState::Join { - return Err!(Request(BadJson( - "Not allowed to send a non-join membership event to join endpoint." - ))); - } - - let sender: OwnedUserId = serde_json::from_value( - value - .get("sender") - .ok_or_else(|| err!(Request(BadJson("Event missing sender property."))))? - .clone() - .into(), - ) - .map_err(|e| err!(Request(BadJson(warn!("sender property is not a valid user ID: {e}")))))?; - - // check if origin server is trying to send for another server - if sender.server_name() != origin { - return Err!(Request(Forbidden("Not allowed to join on behalf of another server."))); - } - - let state_key: OwnedUserId = serde_json::from_value( - value - .get("state_key") - .ok_or_else(|| err!(Request(BadJson("Event missing state_key property."))))? - .clone() - .into(), - ) - .map_err(|e| err!(Request(BadJson(warn!("State key is not a valid user ID: {e}")))))?; - - if state_key != sender { - return Err!(Request(BadJson("State key does not match sender user."))); - } - if let Some(authorising_user) = content.join_authorized_via_users_server { - use ruma::RoomVersionId::*; - - if matches!(room_version, V1 | V2 | V3 | V4 | V5 | V6 | V7) { + if !room_version_rules.authorization.restricted_join_rule { return Err!(Request(InvalidParam( - "Room version {room_version} does not support restricted rooms but \ + "Room version does not support restricted rooms but \ join_authorised_via_users_server ({authorising_user}) was found in the event." ))); } @@ -174,8 +107,7 @@ async fn create_join_event( they cannot authorise your join." ))); } - - if !super::user_can_perform_restricted_join(services, &state_key, room_id).await? { + if !super::user_can_perform_restricted_join(services, &sender, room_id).await? { return Err!(Request(UnableToAuthorizeJoin( "Joining user did not pass restricted room's rules." ))); @@ -183,7 +115,7 @@ async fn create_join_event( services .server_keys - .hash_and_sign_event(&mut value, &room_version_rules) + .hash_and_sign_event(&mut pdu, room_version_rules) .map_err(|e| { err!(Request(InvalidParam(warn!("Failed to sign send_join event: {e}")))) })?; @@ -200,7 +132,7 @@ async fn create_join_event( let pdu_id = services .rooms .event_handler - .handle_incoming_pdu(sender.server_name(), room_id, &event_id, value.clone(), false) + .handle_incoming_pdu(sender.server_name(), room_id, event_id, pdu.clone(), false) .boxed() .await? .ok_or_else(|| err!(Request(InvalidParam("Could not accept as timeline event."))))?; @@ -220,14 +152,12 @@ async fn create_join_event( .iter() .try_stream() .broad_filter_map(|event_id| async move { - if omit_members { - if let Ok(e) = event_id.as_ref() { - let pdu = services.rooms.timeline.get_pdu(e).await; - if pdu.is_ok_and(|p| *p.kind() == TimelineEventType::RoomMember) { - trace!("omitting member event {e:?} from returned state"); - // skip members - return None; - } + if omit_members && let Ok(e) = event_id.as_ref() { + let pdu = services.rooms.timeline.get_pdu(e).await; + if pdu.is_ok_and(|p| *p.kind() == TimelineEventType::RoomMember) { + trace!("omitting member event {e:?} from returned state"); + // skip members + return None; } } Some(event_id) @@ -289,7 +219,7 @@ async fn create_join_event( Ok(assign!(create_join_event::v2::RoomState::new(), { auth_chain, state, - event: to_raw_value(&CanonicalJsonValue::Object(value)).ok(), + event: to_raw_value(&CanonicalJsonValue::Object(pdu)).ok(), members_omitted: omit_members, servers_in_room, })) @@ -309,24 +239,45 @@ pub(crate) async fn create_join_event_v2_route( return Err!(Request(Forbidden("Server is banned on this homeserver."))); } - if let Some(server) = body.room_id.server_name() { - if services.moderation.is_remote_server_forbidden(server) { - warn!( - "Server {} tried joining room ID {} through us which has a server name that is \ - globally forbidden. Rejecting.", - body.identity, &body.room_id, - ); - return Err!(Request(Forbidden(warn!( - "Room ID server name {server} is banned on this homeserver." - )))); - } + let room_version = services.rooms.state.get_room_version(&body.room_id).await?; + let create_event = services + .rooms + .state_accessor + .get_room_create_event(&body.room_id) + .await; + let room_version_rules = room_version.rules().unwrap(); + + let (value, membership, sender, target) = validate_any_membership_event( + &services, + &body.pdu, + &room_version_rules, + create_event.event_id.clone(), + body.room_id.clone(), + body.event_id.clone(), + ) + .await?; + if membership != MembershipState::Join { + return Err!(Request(InvalidParam("Invalid membership (expected `join`)"))); + } + if sender.server_name() != body.identity { + return Err!(Request(InvalidParam("Sender belongs to a different server"))); + } + if sender != target { + return Err!(Request(InvalidParam("Sender does not match state key"))); } let now = Instant::now(); - let room_state = - create_join_event(&services, &body.identity, &body.room_id, &body.pdu, body.omit_members) - .boxed() - .await?; + let room_state = create_join_event( + &services, + &body.identity, + &body.room_id, + &body.event_id, + &room_version_rules, + value, + body.omit_members, + ) + .boxed() + .await?; info!( "Finished sending a join for {} in {} in {:?}", body.identity, diff --git a/src/api/server/send_knock.rs b/src/api/server/send_knock.rs index 377a8792d..905b73a65 100644 --- a/src/api/server/send_knock.rs +++ b/src/api/server/send_knock.rs @@ -1,21 +1,11 @@ use axum::extract::State; -use conduwuit::{ - Err, Result, err, info, - matrix::{event::gen_event_id_canonical_json, pdu::PduEvent}, - warn, -}; +use conduwuit::{Err, Event, Result, debug_info, err, matrix::pdu::PduEvent, warn}; use futures::FutureExt; use ruma::{ - OwnedUserId, - api::federation::membership::create_knock_event, - events::{ - StateEventType, - room::member::{MembershipState, RoomMemberEventContent}, - }, - serde::JsonObject, + api::federation::membership::create_knock_event, events::room::member::MembershipState, }; -use crate::Ruma; +use crate::{Ruma, server::utils::validate_any_membership_event}; /// # `PUT /_matrix/federation/v1/send_knock/{roomId}/{eventId}` /// @@ -33,18 +23,7 @@ pub(crate) async fn create_knock_event_v1_route( forbidden. Rejecting.", body.identity, &body.room_id, ); - return Err!(Request(Forbidden("Server is banned on this homeserver."))); - } - - if let Some(server) = body.room_id.server_name() { - if services.moderation.is_remote_server_forbidden(server) { - warn!( - "Server {} tried knocking room ID {} which has a server name that is globally \ - forbidden. Rejecting.", - body.identity, &body.room_id, - ); - return Err!(Request(Forbidden("Server is banned on this homeserver."))); - } + return Err!(Request(Forbidden("Federation denied with {}", body.identity))); } if !services.rooms.metadata.exists(&body.room_id).await { @@ -57,8 +36,9 @@ pub(crate) async fn create_knock_event_v1_route( .server_in_room(services.globals.server_name(), &body.room_id) .await { - info!( + debug_info!( origin = body.identity.as_str(), + room_id = %body.room_id, "Refusing to serve send_knock for room we aren't participating in" ); return Err!(Request(NotFound("This server is not participating in that room."))); @@ -72,88 +52,40 @@ pub(crate) async fn create_knock_event_v1_route( .await?; let room_version = services.rooms.state.get_room_version(&body.room_id).await?; + let create_event = services + .rooms + .state_accessor + .get_room_create_event(&body.room_id) + .await; let room_version_rules = room_version.rules().unwrap(); if !room_version_rules.authorization.knocking { return Err!(Request(Forbidden("Room version does not support knocking."))); } - let Ok((event_id, value)) = gen_event_id_canonical_json(&body.pdu, &room_version_rules) - else { - // Event could not be converted to canonical json - return Err!(Request(InvalidParam("Could not convert event to canonical json."))); - }; - - let event_type: StateEventType = serde_json::from_value( - value - .get("type") - .ok_or_else(|| err!(Request(InvalidParam("Event has no event type."))))? - .clone() - .into(), + let (mut event, target_membership, sender, target) = validate_any_membership_event( + &services, + &body.pdu, + &room_version_rules, + create_event.event_id().to_owned(), + body.room_id.clone(), + body.event_id.clone(), ) - .map_err(|e| err!(Request(InvalidParam("Event has invalid event type: {e}"))))?; + .await?; - if event_type != StateEventType::RoomMember { - return Err!(Request(InvalidParam( - "Not allowed to send non-membership state event to knock endpoint.", - ))); + if target_membership != MembershipState::Knock { + return Err!(Request(InvalidParam("Invalid membership (expected `knock`)"))); } - - let content: RoomMemberEventContent = serde_json::from_value( - value - .get("content") - .ok_or_else(|| err!(Request(InvalidParam("Membership event has no content"))))? - .clone() - .into(), - ) - .map_err(|e| err!(Request(InvalidParam("Event has invalid membership content: {e}"))))?; - - if content.membership != MembershipState::Knock { - return Err!(Request(InvalidParam( - "Not allowed to send a non-knock membership event to knock endpoint." - ))); - } - - // ACL check sender server name - let sender: OwnedUserId = serde_json::from_value( - value - .get("sender") - .ok_or_else(|| err!(Request(InvalidParam("Event has no sender user ID."))))? - .clone() - .into(), - ) - .map_err(|e| err!(Request(BadJson("Event sender is not a valid user ID: {e}"))))?; - - services - .rooms - .event_handler - .acl_check(sender.server_name(), &body.room_id) - .await?; - - // check if origin server is trying to send for another server if sender.server_name() != body.identity { - return Err!(Request(BadJson("Not allowed to knock on behalf of another server/user."))); + return Err!(Request(InvalidParam("Sender belongs to a different server"))); + } + if sender != target { + return Err!(Request(InvalidParam("Sender does not match state key"))); } - let state_key: OwnedUserId = serde_json::from_value( - value - .get("state_key") - .ok_or_else(|| err!(Request(InvalidParam("Event does not have a state_key"))))? - .clone() - .into(), - ) - .map_err(|e| err!(Request(BadJson("Event does not have a valid state_key: {e}"))))?; + event.insert("event_id".to_owned(), body.event_id.as_str().into()); - if state_key != sender { - return Err!(Request(InvalidParam("state_key does not match sender user of event."))); - } - - let mut event: JsonObject = serde_json::from_str(body.pdu.get()) - .map_err(|e| err!(Request(InvalidParam("Invalid knock event PDU: {e}"))))?; - - event.insert("event_id".to_owned(), "$placeholder".into()); - - let pdu: PduEvent = serde_json::from_value(event.into()) + let pdu = PduEvent::from_id_val(&body.event_id, event.clone()) .map_err(|e| err!(Request(InvalidParam("Invalid knock event PDU: {e}"))))?; let mutex_lock = services @@ -166,7 +98,7 @@ pub(crate) async fn create_knock_event_v1_route( let pdu_id = services .rooms .event_handler - .handle_incoming_pdu(sender.server_name(), &body.room_id, &event_id, value.clone(), false) + .handle_incoming_pdu(sender.server_name(), &body.room_id, &body.event_id, event, false) .boxed() .await? .ok_or_else(|| err!(Request(InvalidParam("Could not accept as timeline event."))))?; diff --git a/src/api/server/send_leave.rs b/src/api/server/send_leave.rs index 121683224..cbbb4d475 100644 --- a/src/api/server/send_leave.rs +++ b/src/api/server/send_leave.rs @@ -1,18 +1,11 @@ use axum::extract::State; -use conduwuit::{Err, Result, err, info, matrix::event::gen_event_id_canonical_json}; -use conduwuit_service::Services; +use conduwuit::{Err, Result, debug_info, err}; use futures::FutureExt; use ruma::{ - OwnedRoomId, OwnedUserId, RoomId, ServerName, - api::federation::membership::create_leave_event, - events::{ - StateEventType, - room::member::{MembershipState, RoomMemberEventContent}, - }, + api::federation::membership::create_leave_event, events::room::member::MembershipState, }; -use serde_json::value::RawValue as RawJsonValue; -use crate::Ruma; +use crate::{Ruma, server::utils::validate_any_membership_event}; /// # `PUT /_matrix/federation/v2/send_leave/{roomId}/{eventId}` /// @@ -21,17 +14,8 @@ pub(crate) async fn create_leave_event_v2_route( State(services): State, body: Ruma, ) -> Result { - create_leave_event(&services, &body.identity, &body.room_id, &body.pdu).await?; - - Ok(create_leave_event::v2::Response::new()) -} - -async fn create_leave_event( - services: &Services, - origin: &ServerName, - room_id: &RoomId, - pdu: &RawJsonValue, -) -> Result { + let room_id = body.room_id.as_ref(); + let origin = &body.identity; if !services.rooms.metadata.exists(room_id).await { return Err!(Request(NotFound("Room is unknown to this server."))); } @@ -42,9 +26,10 @@ async fn create_leave_event( .server_in_room(services.globals.server_name(), room_id) .await { - info!( + debug_info!( origin = origin.as_str(), - "Refusing to serve backfill for room we aren't participating in" + room_id = %room_id, + "Refusing to send_leave for room we aren't participating in" ); return Err!(Request(NotFound("This server is not participating in that room."))); } @@ -56,91 +41,31 @@ async fn create_leave_event( .acl_check(origin, room_id) .await?; - // We do not add the event_id field to the pdu here because of signature and - // hashes checks let room_version = services.rooms.state.get_room_version(room_id).await?; + let create_event = services + .rooms + .state_accessor + .get_room_create_event(room_id) + .await; let room_version_rules = room_version.rules().unwrap(); - let Ok((event_id, value)) = gen_event_id_canonical_json(pdu, &room_version_rules) else { - // Event could not be converted to canonical json - return Err!(Request(BadJson("Could not convert event to canonical json."))); - }; - - let event_room_id: OwnedRoomId = serde_json::from_value( - serde_json::to_value( - value - .get("room_id") - .ok_or_else(|| err!(Request(BadJson("Event missing room_id property."))))?, - ) - .expect("CanonicalJson is valid json value"), + let (value, membership, sender, target) = validate_any_membership_event( + &services, + &body.pdu, + &room_version_rules, + create_event.event_id.clone(), + body.room_id.clone(), + body.event_id.clone(), ) - .map_err(|e| err!(Request(BadJson(warn!("room_id field is not a valid room ID: {e}")))))?; - - if event_room_id != room_id { - return Err!(Request(BadJson("Event room_id does not match request path room ID."))); + .await?; + if membership != MembershipState::Leave { + return Err!(Request(InvalidParam("Invalid membership (expected `leave`)"))); } - - let content: RoomMemberEventContent = serde_json::from_value( - value - .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}")))))?; - - if content.membership != MembershipState::Leave { - return Err!(Request(BadJson( - "Not allowed to send a non-leave membership event to leave endpoint." - ))); + if sender.server_name() != body.identity { + return Err!(Request(InvalidParam("Sender belongs to a different server"))); } - - let event_type: StateEventType = serde_json::from_value( - value - .get("type") - .ok_or_else(|| err!(Request(BadJson("Event missing type property."))))? - .clone() - .into(), - ) - .map_err(|e| err!(Request(BadJson(warn!("Event has invalid state event type: {e}")))))?; - - if event_type != StateEventType::RoomMember { - return Err!(Request(BadJson( - "Not allowed to send non-membership state event to leave endpoint." - ))); - } - - // ACL check sender server name - let sender: OwnedUserId = serde_json::from_value( - value - .get("sender") - .ok_or_else(|| err!(Request(BadJson("Event missing sender property."))))? - .clone() - .into(), - ) - .map_err(|e| err!(Request(BadJson(warn!("sender property is not a valid user ID: {e}")))))?; - - services - .rooms - .event_handler - .acl_check(sender.server_name(), room_id) - .await?; - - if sender.server_name() != origin { - return Err!(Request(BadJson("Not allowed to leave on behalf of another server/user."))); - } - - let state_key: OwnedUserId = serde_json::from_value( - value - .get("state_key") - .ok_or_else(|| err!(Request(BadJson("Event missing state_key property."))))? - .clone() - .into(), - ) - .map_err(|e| err!(Request(BadJson(warn!("State key is not a valid user ID: {e}")))))?; - - if state_key != sender { - return Err!(Request(BadJson("State key does not match sender user."))); + if sender != target { + return Err!(Request(InvalidParam("Sender does not match state key"))); } let mutex_lock = services @@ -153,7 +78,7 @@ async fn create_leave_event( let pdu_id = services .rooms .event_handler - .handle_incoming_pdu(origin, room_id, &event_id, value, false) + .handle_incoming_pdu(origin, room_id, &body.event_id, value, false) .boxed() .await? .ok_or_else(|| err!(Request(InvalidParam("Could not accept as timeline event."))))?; @@ -164,5 +89,7 @@ async fn create_leave_event( .sending .send_pdu_room(room_id, &pdu_id) .boxed() - .await + .await?; + + Ok(create_leave_event::v2::Response::new()) } diff --git a/src/api/server/utils.rs b/src/api/server/utils.rs index 52912dfdc..b420c59a9 100644 --- a/src/api/server/utils.rs +++ b/src/api/server/utils.rs @@ -1,7 +1,10 @@ -use conduwuit::{Err, Result, is_false}; +use conduwuit::{Err, Result, err, is_false}; use conduwuit_service::Services; use futures::{FutureExt, future::OptionFuture, join}; -use ruma::{EventId, RoomId, ServerName}; +use ruma::{ + CanonicalJsonObject, EventId, OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, ServerName, + UserId, events::room::member::MembershipState, room_version_rules::RoomVersionRules, +}; pub(super) struct AccessCheck<'a> { pub(super) services: &'a Services, @@ -63,3 +66,101 @@ pub(super) async fn assert(&self) -> Result { Ok(()) } } + +/// Performs validation on a membership event that should be run on any event a +/// remote is trying to send via us. +/// +/// ## Checks performed +/// +/// 1. PDU room ID matches request path room ID +/// 2. PDU event ID matches request path event ID +/// 3. Signature check +/// 4. Event type check +/// 5. `sender` field presence (and parsing) +/// 6. `state_key` field presence (and parsing) +/// 7. PDU room format check (PDU check 1) +/// +/// ## Returns +/// +/// A resulting tuple of (PDU JSON, target membership state, sender, recipient). +pub(crate) async fn validate_any_membership_event( + services: &crate::State, + body: &serde_json::value::RawValue, + room_version_rules: &RoomVersionRules, + create_event_id: OwnedEventId, + expected_room_id: OwnedRoomId, + expected_event_id: OwnedEventId, +) -> Result<(CanonicalJsonObject, MembershipState, OwnedUserId, OwnedUserId)> { + let (template_room_id, template_event_id, pdu) = services + .rooms + .event_handler + .parse_incoming_pdu(body) + .await + .map_err(|e| err!(Request(BadJson("Invalid membership PDU: {e}"))))?; + + if template_room_id != expected_room_id { + return Err!(Request(InvalidParam("Membership event does not belong to requested room"))); + } + if template_event_id != expected_event_id { + return Err!(Request(InvalidParam( + "Membership event ID does not match provided event ID" + ))); + } + + services + .server_keys + .verify_event(&pdu, room_version_rules) + .await + .map_err(|e| { + err!(Request(InvalidParam("Signature verification failed on membership event: {e}"))) + })?; + + // Ensure this is a membership event + if pdu + .get("type") + .expect("event must have a type") + .as_str() + .expect("type must be a string") + != "m.room.member" + { + return Err!(Request(BadJson( + "Not allowed to send non-membership event to this endpoint" + ))); + } + let membership = pdu + .get("content") + .ok_or_else(|| err!(Request(BadJson("Event missing content property"))))? + .as_object() + .ok_or_else(|| err!(Request(BadJson("Event content is not an object"))))? + .get("membership") + .ok_or_else(|| err!(Request(BadJson("Event missing membership property"))))? + .as_str() + .ok_or_else(|| err!(Request(BadJson("Event is not a string"))))? + .to_owned(); + + let sender_user = pdu + .get("sender") + .and_then(|v| v.as_str()) + .map(UserId::parse) + .and_then(Result::ok) + .ok_or_else(|| err!(Request(InvalidParam("Invalid sender property"))))?; + let recipient_user = pdu + .get("state_key") + .and_then(|v| v.as_str()) + .map(UserId::parse) + .and_then(Result::ok) + .ok_or_else(|| err!(Request(InvalidParam("Invalid state_key property"))))?; + + // Do a quick format check. The spec doesn't suggest this, but it's probably + // a good idea nonetheless. + service::rooms::event_handler::Service::pdu_format_check_1( + &pdu, + room_version_rules, + &create_event_id, + ) + .map_err(|e| { + err!(Request(InvalidParam("Membership event violates the room event format: {e}"))) + })?; + + Ok((pdu, membership.into(), sender_user, recipient_user)) +} diff --git a/src/service/rooms/event_handler/handle_outlier_pdu.rs b/src/service/rooms/event_handler/handle_outlier_pdu.rs index 72285406d..843dea038 100644 --- a/src/service/rooms/event_handler/handle_outlier_pdu.rs +++ b/src/service/rooms/event_handler/handle_outlier_pdu.rs @@ -14,10 +14,12 @@ impl super::Service { /// This performs steps 1 through 4 of [S-S section 5.1][spec], returning /// the parsed PDU and modified JSON object. /// + /// **External callers likely want `handle_incoming_pdu` instead.** + /// /// [spec]: https://spec.matrix.org/v1.19/server-server-api/#checks-performed-on-receipt-of-a-pdu #[allow(clippy::too_many_arguments)] #[tracing::instrument(name = "handle_outlier", skip_all)] - pub(super) async fn handle_outlier_pdu<'a, Pdu>( + pub async fn handle_outlier_pdu<'a, Pdu>( &self, origin: &'a ServerName, create_event: &'a Pdu, @@ -54,7 +56,6 @@ pub(super) async fn handle_outlier_pdu<'a, Pdu>( })?; value.remove("unsigned"); - let room_version_rules = get_room_version_rules(create_event)?; // 2. Check signatures, otherwise drop. // 3. Check content hash, redacting the event if it fails. diff --git a/src/service/rooms/event_handler/pdu_checks.rs b/src/service/rooms/event_handler/pdu_checks.rs index 1288b97a9..1f84e2b41 100644 --- a/src/service/rooms/event_handler/pdu_checks.rs +++ b/src/service/rooms/event_handler/pdu_checks.rs @@ -18,7 +18,7 @@ impl super::Service { /// Checks that the PDU conforms to the PDU format (check 1). This is /// already mostly done during deserialisation, so this function just checks /// that the PDU isn't a too large. - pub(super) fn pdu_format_check_1( + pub fn pdu_format_check_1( pdu_json: &CanonicalJsonObject, room_version_rules: &RoomVersionRules, create_event_id: &EventId, @@ -45,6 +45,8 @@ pub(super) fn pdu_format_check_1( let create_event_in_auth_events = auth_events.iter().any(|id| id == create_event_id); if !event_format.allow_room_create_in_auth_events && create_event_in_auth_events { return Err!(Request(BadJson("PDU references a create event"))); + } else if event_format.allow_room_create_in_auth_events && !create_event_in_auth_events { + return Err!(Request(BadJson("PDU does not reference the room create event"))); } let prev_events = expect_event_id_array(pdu_json, "prev_events")?; @@ -59,7 +61,7 @@ pub(super) fn pdu_format_check_1( /// the content hash verification fails (check 3), returning the /// potentially modified JSON. Returns an error if the PDU cannot be /// redacted, or fails signature verification. - pub(super) async fn signature_hash_check_2_3( + pub async fn signature_hash_check_2_3( &self, pdu_json: CanonicalJsonObject, room_version_rules: &RoomVersionRules, @@ -92,7 +94,7 @@ pub(super) async fn signature_hash_check_2_3( /// If the auth check fails, false is returned, otherwise true. /// /// [spec]: https://spec.matrix.org/v1.19/server-server-api/#checks-performed-on-receipt-of-a-pdu - pub(super) async fn auth_state_check_4( + pub async fn auth_state_check_4( &self, pdu: &PduEvent, room_version_rules: &RoomVersionRules,