From fdd21ce7c16bf898a6d1dfd56488ba3389a3a72a Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Fri, 30 Oct 2026 10:16:29 +0100 Subject: [PATCH] Accept knock denials and bans from the remembered server MSC4233 "Rejecting the knock": a resident server that kicks or bans a knocking user now also sends the event to that user's server, which is not in the room. The sender fans out a leave or ban whose auth events hold the target's own knock, as it already does for a rescinded invite. On the knocking side, `on_receive_pdu` accepts such a leave or ban as an out-of-band membership when the user's current membership is the knock and the knock is in the event's `auth_events`. It takes the event only from the remembered server or from the sender's own homeserver. We cannot auth the event against room state, so the origin check is what keeps an unrelated server from ending a local user's knock. The accepted event gets `unsigned.replaces_state` pointing at the knock. Outliers never get one from state resolution, and without it `/sync` and `/context` cannot present the knock as `prev_content`, which is how a client tells a denied knock from a kick. That is a client-visible change, so the MSC's "There are no client-visible changes" sentence needs amending, or this stamping comes out. An out-of-band ban gives `local_current_membership = ban` for a room we are not in. `/sync` lists it under `rooms.leave` and `/forget` accepts it. `/leave` on it now returns 403 `M_BAD_STATE` instead of failing an assertion: we hold no state to build a leave against, and the room would refuse one from a banned user. There is no path back. We accept only `knock -> leave/ban` out of band, so an unban in the room never reaches us and the user stays banned locally. Co-authored-by: Quentin Gliech Co-Authored-By: Claude Fable 5.1 --- synapse/federation/sender/__init__.py | 32 +++++++++---- synapse/handlers/federation_event.py | 61 ++++++++++++++++++++++-- synapse/handlers/room_member.py | 11 +++++ synapse/storage/databases/main/events.py | 15 +++--- 4 files changed, 101 insertions(+), 18 deletions(-) diff --git a/synapse/federation/sender/__init__.py b/synapse/federation/sender/__init__.py index a218ca74cb..22a4292856 100644 --- a/synapse/federation/sender/__init__.py +++ b/synapse/federation/sender/__init__.py @@ -696,18 +696,23 @@ class FederationSender(AbstractFederationSender): ) return - # If we've rescinded an invite then we want to tell the - # other server. + # If we've rescinded an invite, or denied a knock + # (MSC4233), then we want to tell the other server. + msc4233_enabled = self.hs.config.experimental.msc4233_enabled if ( event.type == EventTypes.Member - and event.membership == Membership.LEAVE and event.sender != event.state_key + and ( + event.membership == Membership.LEAVE + or (msc4233_enabled and event.membership == Membership.BAN) + ) ): - # We check if this leave event is rescinding an invite - # by looking if there is an invite event for the user in - # the auth events. It could otherwise be a kick or - # unban, which we don't want to send (if the user wasn't - # already in the room). + # A leave or ban whose auth events contain the + # target's own invite or knock is a rescinded invite or + # a denied knock, and the target's server needs to hear + # about it even though it is not in the room. Anything + # else is a kick or an unban of a user who was already + # in the room, which we do not send. auth_events = await self.store.get_events_as_list( event.auth_event_ids() ) @@ -715,7 +720,16 @@ class FederationSender(AbstractFederationSender): if ( auth_event.type == EventTypes.Member and auth_event.state_key == event.state_key - and auth_event.membership == Membership.INVITE + and ( + ( + event.membership == Membership.LEAVE + and auth_event.membership == Membership.INVITE + ) + or ( + msc4233_enabled + and auth_event.membership == Membership.KNOCK + ) + ) ): destinations = set(destinations) destinations.add(get_domain_from_id(event.state_key)) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 1ea0e3524d..d542dd704b 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -258,12 +258,17 @@ class FederationEventHandler: room_id, self.server_name ) if not is_in_room: - # Check if this is a leave event rescinding an invite + # Check if this is a leave event rescinding an invite, or denying + # a knock (MSC4233) + msc4233_enabled = self._config.experimental.msc4233_enabled if ( pdu.type == EventTypes.Member - and pdu.membership == Membership.LEAVE and pdu.state_key != pdu.sender and self._is_mine_id(pdu.state_key) + and ( + pdu.membership == Membership.LEAVE + or (msc4233_enabled and pdu.membership == Membership.BAN) + ) ): ( membership, @@ -272,7 +277,8 @@ class FederationEventHandler: pdu.state_key, pdu.room_id ) if ( - membership == Membership.INVITE + pdu.membership == Membership.LEAVE + and membership == Membership.INVITE and membership_event_id and membership_event_id in pdu.auth_event_ids() # The invite should be in the auth events of the rescission. @@ -294,6 +300,55 @@ class FederationEventHandler: context = EventContext.for_outlier(self._storage_controllers) await self.persist_events_and_notify(room_id, [(pdu, context)]) return + elif ( + msc4233_enabled + and membership == Membership.KNOCK + and membership_event_id + and membership_event_id + in pdu.auth_event_ids() # The knock should be in the auth events of the denial. + ): + # A local user's knock on this remote room is being denied + # (or the user banned). + # + # We cannot fully auth the denial event, but the sender has + # demonstrated knowledge of our knock by referencing it in + # the auth events, and the event's signature (checked on + # receipt) proves it comes from the sender's server. On top + # of that, only accept the event from the server the knock + # was fulfilled through, or from the denying user's own + # server. + # + # We cannot verify that the sender has the power level + # required to deny the knock, as with invite rescission + # above. + # + # `via_server` is `None` for a knock made before MSC4233 + # was enabled, and for a record left over from an earlier + # knock on the same room. `origin` is always a server name, + # so the first comparison below fails and only the sender's + # own server is accepted. + via_server = await self._store.get_local_knock_via_server( + pdu.state_key, pdu.room_id, membership_event_id + ) + if origin == via_server or origin == get_domain_from_id(pdu.sender): + # An outlier never gets a `replaces_state` from state + # resolution, so record the knock it replaces here. + # Readers that ask for `get_prev_content` expand that + # into `prev_content`/`prev_sender` when they load the + # event, which is how a client tells a denied knock + # from a plain kick. + knock_event = await self._store.get_event( + membership_event_id, allow_none=True + ) + if knock_event is not None: + pdu.unsigned["replaces_state"] = knock_event.event_id + + # Handle the denial event + pdu.internal_metadata.outlier = True + pdu.internal_metadata.out_of_band_membership = True + context = EventContext.for_outlier(self._storage_controllers) + await self.persist_events_and_notify(room_id, [(pdu, context)]) + return logger.info( "Ignoring PDU from %s as we're not in the room", diff --git a/synapse/handlers/room_member.py b/synapse/handlers/room_member.py index 3f5ab689b5..358d464a23 100644 --- a/synapse/handlers/room_member.py +++ b/synapse/handlers/room_member.py @@ -1200,6 +1200,17 @@ class RoomMemberHandler(metaclass=abc.ABCMeta): knock.event_id, txn_id, requester, content ) + # or perhaps a knock on a remote room was denied with a ban + # (MSC4233). We have no state for the room to build a leave + # against, and the room would reject a leave from a banned + # user in any case. + elif current_membership_type == Membership.BAN: + raise SynapseError( + 403, + "Cannot leave a room you were banned from", + errcode=Codes.BAD_STATE, + ) + elif effective_membership_state == Membership.KNOCK: if not is_host_in_room: # we used to add the domain of the room ID to remote_room_hosts. diff --git a/synapse/storage/databases/main/events.py b/synapse/storage/databases/main/events.py index ef2110292d..8ea2cb6bd8 100644 --- a/synapse/storage/databases/main/events.py +++ b/synapse/storage/databases/main/events.py @@ -3631,13 +3631,15 @@ class PersistEventsStore: and event.internal_metadata.is_out_of_band_membership() ): # The only sort of out-of-band-membership events we expect to see here - # are remote invites/knocks and LEAVE events corresponding to - # rejected/retracted invites and rescinded knocks. + # are remote invites/knocks, LEAVE events corresponding to + # rejected/retracted invites and rescinded knocks, and LEAVE/BAN + # events corresponding to denied knocks (MSC4233). assert event.type == EventTypes.Member assert event.membership in ( Membership.INVITE, Membership.KNOCK, Membership.LEAVE, + Membership.BAN, ) self.db_pool.simple_upsert_txn( @@ -3672,10 +3674,11 @@ class PersistEventsStore: "event_stream_ordering": event.internal_metadata.stream_ordering, "event_instance_name": event.internal_metadata.instance_name, } - if event.membership == Membership.LEAVE: - # Inherit the meta data from the remote invite/knock. When using - # sliding sync filters, this will prevent the room from - # disappearing/appearing just because you left the room. + if event.membership in (Membership.LEAVE, Membership.BAN): + # Inherit the meta data from the remote invite/knock (a BAN here is + # a denied knock, MSC4233). When using sliding sync filters, this + # will prevent the room from disappearing/appearing just because + # you left the room. pass elif event.membership in (Membership.INVITE, Membership.KNOCK): extra_insert_values = (