mirror of
https://forgejo.ellis.link/continuwuation/continuwuity.git
synced 2026-05-26 20:49:55 +00:00
12aecf8091
This fixes a vulnerability where an attacker with a malicious remote server and a user on the local server can trick the local server into signing arbitrary events. The attacker issue a remote leave as the local user to a room on the malicious server. Without any validation of the make_leave response, the local server would sign the attacker-controlled event and pass it back to the malicious server with send_leave. The join and knock endpoints are also fixed in this commit, but are less useful for exploitation because the local server replaces the "content" field returned by the remote server. Remote invites are unaffected because we already check that the event returned from /invite has the same event ID as the event passed to it. Co-authored-by: timedout <git@nexy7574.co.uk> Co-authored-by: Jade Ellis <jade@ellis.link> Co-authored-by: Ginger <ginger@gingershaped.computer>
405 lines
10 KiB
Rust
405 lines
10 KiB
Rust
use std::collections::HashSet;
|
|
|
|
use axum::extract::State;
|
|
use conduwuit::{
|
|
Err, Pdu, Result, debug_info, debug_warn, err,
|
|
matrix::{event::gen_event_id, pdu::PduBuilder},
|
|
utils::{self, FutureBoolExt, future::ReadyEqExt},
|
|
warn,
|
|
};
|
|
use futures::{FutureExt, StreamExt, pin_mut};
|
|
use ruma::{
|
|
CanonicalJsonObject, CanonicalJsonValue, OwnedServerName, RoomId, RoomVersionId, UserId,
|
|
api::{
|
|
client::membership::leave_room,
|
|
federation::{self},
|
|
},
|
|
events::{
|
|
StateEventType,
|
|
room::member::{MembershipState, RoomMemberEventContent},
|
|
},
|
|
};
|
|
use service::Services;
|
|
|
|
use super::validate_remote_member_event_stub;
|
|
use crate::Ruma;
|
|
|
|
/// # `POST /_matrix/client/v3/rooms/{roomId}/leave`
|
|
///
|
|
/// Tries to leave the sender user from a room.
|
|
///
|
|
/// - This should always work if the user is currently joined.
|
|
pub(crate) async fn leave_room_route(
|
|
State(services): State<crate::State>,
|
|
body: Ruma<leave_room::v3::Request>,
|
|
) -> Result<leave_room::v3::Response> {
|
|
leave_room(&services, body.sender_user(), &body.room_id, body.reason.clone())
|
|
.boxed()
|
|
.await
|
|
.map(|()| leave_room::v3::Response::new())
|
|
}
|
|
|
|
// Make a user leave all their joined rooms, rescinds knocks, forgets all rooms,
|
|
// and ignores errors
|
|
pub async fn leave_all_rooms(services: &Services, user_id: &UserId) {
|
|
let rooms_joined = services
|
|
.rooms
|
|
.state_cache
|
|
.rooms_joined(user_id)
|
|
.map(ToOwned::to_owned);
|
|
|
|
let rooms_invited = services
|
|
.rooms
|
|
.state_cache
|
|
.rooms_invited(user_id)
|
|
.map(|(r, _)| r);
|
|
|
|
let rooms_knocked = services
|
|
.rooms
|
|
.state_cache
|
|
.rooms_knocked(user_id)
|
|
.map(|(r, _)| r);
|
|
|
|
let all_rooms: Vec<_> = rooms_joined
|
|
.chain(rooms_invited)
|
|
.chain(rooms_knocked)
|
|
.collect()
|
|
.await;
|
|
|
|
for room_id in all_rooms {
|
|
// ignore errors
|
|
if let Err(e) = leave_room(services, user_id, &room_id, None).boxed().await {
|
|
warn!(%user_id, "Failed to leave {room_id} remotely: {e}");
|
|
}
|
|
|
|
services.rooms.state_cache.forget(&room_id, user_id);
|
|
}
|
|
}
|
|
|
|
pub async fn leave_room(
|
|
services: &Services,
|
|
user_id: &UserId,
|
|
room_id: &RoomId,
|
|
reason: Option<String>,
|
|
) -> Result {
|
|
let is_banned = services.rooms.metadata.is_banned(room_id);
|
|
let is_disabled = services.rooms.metadata.is_disabled(room_id);
|
|
|
|
let dont_have_room = services
|
|
.rooms
|
|
.state_cache
|
|
.server_in_room(services.globals.server_name(), room_id)
|
|
.eq(&false);
|
|
|
|
let not_knocked = services
|
|
.rooms
|
|
.state_cache
|
|
.is_knocked(user_id, room_id)
|
|
.eq(&false);
|
|
|
|
pin_mut!(is_banned, is_disabled);
|
|
|
|
/*
|
|
there are three possible cases when leaving a room:
|
|
1. the room is banned or disabled, so we're not federating with it.
|
|
2. nobody on the homeserver is in the room, which can happen if the user is rejecting an invite
|
|
to a room that we don't have any members in.
|
|
3. someone else on the homeserver is in the room. in this case we can leave like normal by sending a PDU over federation.
|
|
|
|
in cases 1 and 2, we have to update the state cache using `mark_as_left` directly.
|
|
otherwise `build_and_append_pdu` will take care of updating the state cache for us.
|
|
*/
|
|
|
|
// `leave_pdu` is the outlier `m.room.member` event which will be synced to the
|
|
// user. if it's None the sync handler will create a dummy PDU.
|
|
let leave_pdu = if is_banned.or(is_disabled).await {
|
|
// case 1: the room is banned/disabled. we don't want to federate with another
|
|
// server to leave, so we can't create an outlier PDU.
|
|
None
|
|
} else if dont_have_room.and(not_knocked).await {
|
|
// case 2: ask a remote server to assist us with leaving
|
|
// we always mark the room as left locally, regardless of if the federated leave
|
|
// failed
|
|
|
|
remote_leave_room(services, user_id, room_id, reason.clone(), HashSet::new())
|
|
.await
|
|
.inspect_err(|err| {
|
|
warn!(%user_id, "Failed to leave room {room_id} remotely: {err}");
|
|
})
|
|
.ok()
|
|
} else {
|
|
// case 3: we can leave by sending a PDU.
|
|
let state_lock = services.rooms.state.mutex.lock(room_id).await;
|
|
|
|
let user_member_event_content = services
|
|
.rooms
|
|
.state_accessor
|
|
.room_state_get_content::<RoomMemberEventContent>(
|
|
room_id,
|
|
&StateEventType::RoomMember,
|
|
user_id.as_str(),
|
|
)
|
|
.await;
|
|
|
|
match user_member_event_content {
|
|
| Ok(content) => {
|
|
services
|
|
.rooms
|
|
.timeline
|
|
.build_and_append_pdu(
|
|
PduBuilder::state(user_id.to_string(), &RoomMemberEventContent {
|
|
membership: MembershipState::Leave,
|
|
reason,
|
|
join_authorized_via_users_server: None,
|
|
is_direct: None,
|
|
..content
|
|
}),
|
|
user_id,
|
|
Some(room_id),
|
|
&state_lock,
|
|
)
|
|
.await?;
|
|
|
|
// `build_and_append_pdu` calls `mark_as_left` internally, so we return early.
|
|
return Ok(());
|
|
},
|
|
| Err(_) => {
|
|
// an exception to case 3 is if the user isn't even in the room they're trying
|
|
// to leave. this can happen if the client's caching is wrong.
|
|
debug_warn!(
|
|
"Trying to leave a room you are not a member of, marking room as left \
|
|
locally."
|
|
);
|
|
|
|
// return the existing leave state, if one exists. `mark_as_left` will then
|
|
// update the `roomuserid_leftcount` table, making the leave come down sync
|
|
// again.
|
|
services
|
|
.rooms
|
|
.state_cache
|
|
.left_state(user_id, room_id)
|
|
.await?
|
|
},
|
|
}
|
|
};
|
|
|
|
services
|
|
.rooms
|
|
.state_cache
|
|
.mark_as_left(user_id, room_id, leave_pdu)
|
|
.await;
|
|
|
|
services
|
|
.rooms
|
|
.state_cache
|
|
.update_joined_count(room_id)
|
|
.await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn remote_leave_room<S: ::std::hash::BuildHasher>(
|
|
services: &Services,
|
|
user_id: &UserId,
|
|
room_id: &RoomId,
|
|
reason: Option<String>,
|
|
mut servers: HashSet<OwnedServerName, S>,
|
|
) -> Result<Pdu> {
|
|
let mut make_leave_response_and_server =
|
|
Err!(BadServerResponse("No remote server available to assist in leaving {room_id}."));
|
|
|
|
servers.extend(
|
|
services
|
|
.rooms
|
|
.state_cache
|
|
.servers_invite_via(room_id)
|
|
.map(ToOwned::to_owned)
|
|
.collect::<HashSet<OwnedServerName>>()
|
|
.await,
|
|
);
|
|
|
|
match services
|
|
.rooms
|
|
.state_cache
|
|
.invite_state(user_id, room_id)
|
|
.await
|
|
{
|
|
| Ok(invite_state) => {
|
|
servers.extend(
|
|
invite_state
|
|
.iter()
|
|
.filter_map(|event| event.get_field("sender").ok().flatten())
|
|
.filter_map(|sender: &str| UserId::parse(sender).ok())
|
|
.map(|user| user.server_name().to_owned()),
|
|
);
|
|
},
|
|
| _ => {
|
|
match services
|
|
.rooms
|
|
.state_cache
|
|
.knock_state(user_id, room_id)
|
|
.await
|
|
{
|
|
| Ok(knock_state) => {
|
|
servers.extend(
|
|
knock_state
|
|
.iter()
|
|
.filter_map(|event| event.get_field("sender").ok().flatten())
|
|
.filter_map(|sender: &str| UserId::parse(sender).ok())
|
|
.filter_map(|sender| {
|
|
if !services.globals.user_is_local(sender) {
|
|
Some(sender.server_name().to_owned())
|
|
} else {
|
|
None
|
|
}
|
|
}),
|
|
);
|
|
},
|
|
| _ => {},
|
|
}
|
|
},
|
|
}
|
|
|
|
if let Some(room_id_server_name) = room_id.server_name() {
|
|
servers.insert(room_id_server_name.to_owned());
|
|
}
|
|
if servers.is_empty() {
|
|
return Err!(BadServerResponse(warn!(
|
|
"No remote servers found to assist in leaving {room_id}."
|
|
)));
|
|
}
|
|
|
|
debug_info!("servers in remote_leave_room: {servers:?}");
|
|
|
|
for remote_server in servers {
|
|
let make_leave_response = services
|
|
.sending
|
|
.send_federation_request(
|
|
remote_server.as_ref(),
|
|
federation::membership::prepare_leave_event::v1::Request {
|
|
room_id: room_id.to_owned(),
|
|
user_id: user_id.to_owned(),
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let error = make_leave_response.as_ref().err().map(ToString::to_string);
|
|
make_leave_response_and_server = make_leave_response.map(|r| (r, remote_server.clone()));
|
|
|
|
if make_leave_response_and_server.is_ok() {
|
|
debug_info!(
|
|
"Received make_leave_response from {} for leaving {room_id}",
|
|
remote_server
|
|
);
|
|
break;
|
|
}
|
|
debug_warn!(
|
|
"Failed to get make_leave_response from {} for leaving {room_id}: {}",
|
|
remote_server,
|
|
error.unwrap()
|
|
);
|
|
}
|
|
|
|
let (make_leave_response, remote_server) = make_leave_response_and_server?;
|
|
|
|
let Some(room_version_id) = make_leave_response.room_version else {
|
|
return Err!(BadServerResponse(warn!(
|
|
"No room version was returned by {remote_server} for {room_id}, room version is \
|
|
likely not supported by continuwuity"
|
|
)));
|
|
};
|
|
|
|
if !services.server.supported_room_version(&room_version_id) {
|
|
return Err!(BadServerResponse(warn!(
|
|
"Remote room version {room_version_id} for {room_id} is not supported by \
|
|
continuwuity",
|
|
)));
|
|
}
|
|
|
|
let mut leave_event_stub = serde_json::from_str::<CanonicalJsonObject>(
|
|
make_leave_response.event.get(),
|
|
)
|
|
.map_err(|e| {
|
|
err!(BadServerResponse(warn!(
|
|
"Invalid make_leave event json received from {remote_server} for {room_id}: {e:?}"
|
|
)))
|
|
})?;
|
|
|
|
validate_remote_member_event_stub(
|
|
&MembershipState::Leave,
|
|
user_id,
|
|
room_id,
|
|
&leave_event_stub,
|
|
)?;
|
|
|
|
// TODO: Is origin needed?
|
|
leave_event_stub.insert(
|
|
"origin".to_owned(),
|
|
CanonicalJsonValue::String(services.globals.server_name().as_str().to_owned()),
|
|
);
|
|
leave_event_stub.insert(
|
|
"origin_server_ts".to_owned(),
|
|
CanonicalJsonValue::Integer(
|
|
utils::millis_since_unix_epoch()
|
|
.try_into()
|
|
.expect("Timestamp is valid js_int value"),
|
|
),
|
|
);
|
|
// Inject the reason key into the event content dict if it exists
|
|
if let Some(reason) = reason {
|
|
if let Some(CanonicalJsonValue::Object(content)) = leave_event_stub.get_mut("content") {
|
|
content.insert("reason".to_owned(), CanonicalJsonValue::String(reason));
|
|
}
|
|
}
|
|
|
|
// room v3 and above removed the "event_id" field from remote PDU format
|
|
match room_version_id {
|
|
| RoomVersionId::V1 | RoomVersionId::V2 => {},
|
|
| _ => {
|
|
leave_event_stub.remove("event_id");
|
|
},
|
|
}
|
|
|
|
// In order to create a compatible ref hash (EventID) the `hashes` field needs
|
|
// to be present
|
|
services
|
|
.server_keys
|
|
.hash_and_sign_event(&mut leave_event_stub, &room_version_id)?;
|
|
|
|
// Generate event id
|
|
let event_id = gen_event_id(&leave_event_stub, &room_version_id)?;
|
|
|
|
// Add event_id back
|
|
leave_event_stub
|
|
.insert("event_id".to_owned(), CanonicalJsonValue::String(event_id.clone().into()));
|
|
|
|
// It has enough fields to be called a proper event now
|
|
let leave_event = leave_event_stub;
|
|
|
|
services
|
|
.sending
|
|
.send_federation_request(
|
|
&remote_server,
|
|
federation::membership::create_leave_event::v2::Request {
|
|
room_id: room_id.to_owned(),
|
|
event_id: event_id.clone(),
|
|
pdu: services
|
|
.sending
|
|
.convert_to_outgoing_federation_event(leave_event.clone())
|
|
.await,
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
services
|
|
.rooms
|
|
.outlier
|
|
.add_pdu_outlier(&event_id, &leave_event);
|
|
|
|
let leave_pdu = Pdu::from_id_val(&event_id, leave_event).map_err(|e| {
|
|
err!(BadServerResponse("Invalid leave PDU received during federated leave: {e:?}"))
|
|
})?;
|
|
|
|
Ok(leave_pdu)
|
|
}
|