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 <quenting@element.io>

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
authored and Quentin Gliech committed 2026-10-30 10:20:36 +01:00
1 parent dd5c807878
commit fdd21ce7c1
4 files changed
+101 -18

No files matched your search

+23 -9
View File
@@ -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))
+58 -3
View File
@@ -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",
+11
View File
@@ -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.
+9 -6
View File
@@ -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 = (