From bdfeb90e6e01718e4b4e7a7ef3313e55360f75d6 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 23 Apr 2026 12:18:04 -0500 Subject: [PATCH 01/23] Client side API's should still use stripped events with MSC4311 --- synapse/events/utils.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/synapse/events/utils.py b/synapse/events/utils.py index f038fb5578d..153352d0110 100644 --- a/synapse/events/utils.py +++ b/synapse/events/utils.py @@ -1019,15 +1019,6 @@ def strip_event(event: EventBase) -> JsonDict: Stripped state events can only have the `sender`, `type`, `state_key` and `content` properties present. """ - # MSC4311: Ensure the create event is available on invites and knocks. - # TODO: Implement the rest of MSC4311 - if ( - event.room_version.msc4291_room_ids_as_hashes - and event.type == EventTypes.Create - and event.get_state_key() == "" - ): - return event.get_pdu_json() - return { "type": event.type, "state_key": event.state_key, From 43a11f5a50ab888bd0b402748b6d7403a3704e6e Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 23 Apr 2026 12:29:51 -0500 Subject: [PATCH 02/23] Add changelog --- changelog.d/19723.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/19723.bugfix diff --git a/changelog.d/19723.bugfix b/changelog.d/19723.bugfix new file mode 100644 index 00000000000..ed635a9f4f8 --- /dev/null +++ b/changelog.d/19723.bugfix @@ -0,0 +1 @@ +Remove flawed [MSC4311](https://github.com/matrix-org/matrix-spec-proposals/pull/4311) partial implementation: Client-side API's like `/sync` should still use stripped events. From 6026aaa9fd500632b525237fd04433a04d06cbb4 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 1 May 2026 19:16:25 -0500 Subject: [PATCH 03/23] Non-working: Use full PDU's for `invite_room_state` in federation --- synapse/federation/federation_client.py | 64 ++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index 78a1900c731..3f42a99c8cc 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -60,6 +60,7 @@ RoomVersions, ) from synapse.events import EventBase, builder, make_event_from_dict +from synapse.events.utils import parse_stripped_state_event from synapse.federation.federation_base import ( FederationBase, InvalidEventSignatureError, @@ -71,7 +72,16 @@ from synapse.http.types import QueryParams from synapse.logging.opentracing import SynapseTags, log_kv, set_tag, tag_args, trace from synapse.metrics import SERVER_NAME_LABEL -from synapse.types import JsonDict, StrCollection, UserID, get_domain_from_id +from synapse.types import ( + JsonDict, + PersistedEventPosition, + StrCollection, + StreamKeyType, + StreamToken, + UserID, + get_domain_from_id, +) +from synapse.types.state import StateFilter from synapse.util.async_helpers import concurrently_execute from synapse.util.caches.expiringcache import ExpiringCache from synapse.util.duration import Duration @@ -135,6 +145,7 @@ def __init__(self, hs: "HomeServer"): self._clock.looping_call(self._clear_tried_cache, Duration(minutes=1)) self.state = hs.get_state_handler() self.transport_layer = hs.get_federation_transport_client() + self.storage_controllers = hs.get_storage_controllers() self.server_name = hs.hostname self.signing_key = hs.signing_key @@ -1350,6 +1361,52 @@ async def _do_send_invite( """ time_now = self._clock.time_msec() + # MSC4311: For the federation API, format events in `invite_room_state` as full + # PDU's + # + # First get all of the expected stripped state events that should be included. + # We will derive these from the `unsigned` part of the PDU but this doesn't + # include any event ID information so we need to look it up based on the state + # at the time of the invite. + stripped_state_types = [] + for raw_stripped_event in pdu.unsigned.get("invite_room_state", []): + stripped_state_event = parse_stripped_state_event(raw_stripped_event) + # Since this is our own invite, it should always be well-formed + assert stripped_state_event is not None, ( + "Unable to parse one of the evnts from the `invite_room_state` as a stripped state event" + ) + stripped_state_types.append( + (stripped_state_event.type, stripped_state_event.state_key) + ) + + assert ( + pdu.internal_metadata.stream_ordering is not None + and pdu.internal_metadata.instance_name is not None + ), "Invite should be persisted by this point" + + # Find the full events based on the state at the time of the invite + state_filter = StateFilter.from_types(stripped_state_types) + state_ids = await self.storage_controllers.state.get_state_ids_at( + pdu.room_id, + stream_position=StreamToken.START.copy_and_replace( + StreamKeyType.ROOM, + PersistedEventPosition( + instance_name=pdu.internal_metadata.instance_name, + stream=pdu.internal_metadata.stream_ordering, + ).to_room_stream_token(), + ), + state_filter=state_filter, + # Partially-stated rooms should have all state events except for remote + # membership events. Since an invite will only possibly include the + # `m.room.membership` of the local sender, we're good to use partial state + # here. + await_full_state=False, + ) + state_events = await self.store.get_events(list(state_ids.values())) + assert set(state_ids.values()) == set(state_events.keys()), ( + "We should have all events available that were set as stripped state." + ) + try: return await self.transport_layer.send_invite_v2( destination=destination, @@ -1358,7 +1415,10 @@ async def _do_send_invite( content={ "event": pdu.get_pdu_json(time_now), "room_version": room_version.identifier, - "invite_room_state": pdu.unsigned.get("invite_room_state", []), + "invite_room_state": [ + state_event.get_pdu_json(time_now) + for state_event in state_events.values() + ], }, ) except HttpResponseException as e: From e0eb224cfa81690b88adf376309dc7b98c7c8202 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 1 May 2026 19:37:35 -0500 Subject: [PATCH 04/23] Iteration that uses current state --- synapse/federation/federation_client.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index 3f42a99c8cc..ad2945e82f5 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -1361,6 +1361,8 @@ async def _do_send_invite( """ time_now = self._clock.time_msec() + # TODO: Adapt and use `get_stripped_room_state_from_event_context` instead + # MSC4311: For the federation API, format events in `invite_room_state` as full # PDU's # @@ -1379,22 +1381,17 @@ async def _do_send_invite( (stripped_state_event.type, stripped_state_event.state_key) ) - assert ( - pdu.internal_metadata.stream_ordering is not None - and pdu.internal_metadata.instance_name is not None - ), "Invite should be persisted by this point" + # assert ( + # pdu.internal_metadata.stream_ordering is not None + # and pdu.internal_metadata.instance_name is not None + # ), "Invite should be persisted by this point" # Find the full events based on the state at the time of the invite state_filter = StateFilter.from_types(stripped_state_types) - state_ids = await self.storage_controllers.state.get_state_ids_at( + # XXX: Ideally, we'd use `get_state_ids_at(...)` but the invite event isn't + # persisted yet so there is no persisted position to look at specfically. + state_ids = await self.storage_controllers.state.get_current_state_ids( pdu.room_id, - stream_position=StreamToken.START.copy_and_replace( - StreamKeyType.ROOM, - PersistedEventPosition( - instance_name=pdu.internal_metadata.instance_name, - stream=pdu.internal_metadata.stream_ordering, - ).to_room_stream_token(), - ), state_filter=state_filter, # Partially-stated rooms should have all state events except for remote # membership events. Since an invite will only possibly include the From 3464ec889406c592f6c75bde30f8dad2ae34bb74 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 1 May 2026 19:53:45 -0500 Subject: [PATCH 05/23] Use `get_stripped_room_state_ids_from_event_context` --- synapse/federation/federation_client.py | 38 ++++++----------- synapse/federation/federation_server.py | 1 + synapse/handlers/federation.py | 6 ++- synapse/handlers/message.py | 2 +- .../storage/databases/main/events_worker.py | 41 +++++++++++++++---- 5 files changed, 52 insertions(+), 36 deletions(-) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index ad2945e82f5..e8347324d1a 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -60,6 +60,7 @@ RoomVersions, ) from synapse.events import EventBase, builder, make_event_from_dict +from synapse.events.snapshot import EventContext from synapse.events.utils import parse_stripped_state_event from synapse.federation.federation_base import ( FederationBase, @@ -74,10 +75,7 @@ from synapse.metrics import SERVER_NAME_LABEL from synapse.types import ( JsonDict, - PersistedEventPosition, StrCollection, - StreamKeyType, - StreamToken, UserID, get_domain_from_id, ) @@ -145,7 +143,6 @@ def __init__(self, hs: "HomeServer"): self._clock.looping_call(self._clear_tried_cache, Duration(minutes=1)) self.state = hs.get_state_handler() self.transport_layer = hs.get_federation_transport_client() - self.storage_controllers = hs.get_storage_controllers() self.server_name = hs.hostname self.signing_key = hs.signing_key @@ -1320,12 +1317,12 @@ async def send_invite( self, destination: str, room_id: str, - event_id: str, pdu: EventBase, + context: EventContext, ) -> EventBase: room_version = await self.store.get_room_version(room_id) - content = await self._do_send_invite(destination, pdu, room_version) + content = await self._do_send_invite(destination, pdu, context, room_version) pdu_dict = content["event"] @@ -1346,7 +1343,11 @@ async def send_invite( return pdu async def _do_send_invite( - self, destination: str, pdu: EventBase, room_version: RoomVersion + self, + destination: str, + pdu: EventBase, + context: EventContext, + room_version: RoomVersion, ) -> JsonDict: """Actually sends the invite, first trying v2 API and falling back to v1 API if necessary. @@ -1361,8 +1362,6 @@ async def _do_send_invite( """ time_now = self._clock.time_msec() - # TODO: Adapt and use `get_stripped_room_state_from_event_context` instead - # MSC4311: For the federation API, format events in `invite_room_state` as full # PDU's # @@ -1381,26 +1380,13 @@ async def _do_send_invite( (stripped_state_event.type, stripped_state_event.state_key) ) - # assert ( - # pdu.internal_metadata.stream_ordering is not None - # and pdu.internal_metadata.instance_name is not None - # ), "Invite should be persisted by this point" - # Find the full events based on the state at the time of the invite state_filter = StateFilter.from_types(stripped_state_types) - # XXX: Ideally, we'd use `get_state_ids_at(...)` but the invite event isn't - # persisted yet so there is no persisted position to look at specfically. - state_ids = await self.storage_controllers.state.get_current_state_ids( - pdu.room_id, - state_filter=state_filter, - # Partially-stated rooms should have all state events except for remote - # membership events. Since an invite will only possibly include the - # `m.room.membership` of the local sender, we're good to use partial state - # here. - await_full_state=False, + state_ids = await self.store.get_stripped_room_state_ids_from_event_context( + context, state_filter ) - state_events = await self.store.get_events(list(state_ids.values())) - assert set(state_ids.values()) == set(state_events.keys()), ( + state_events = await self.store.get_events(state_ids) + assert set(state_ids) == set(state_events.keys()), ( "We should have all events available that were set as stripped state." ) diff --git a/synapse/federation/federation_server.py b/synapse/federation/federation_server.py index 1bbe1444223..7018eea1ad5 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -965,6 +965,7 @@ async def on_send_knock_request( # server. This will allow the remote server's clients to display information # related to the room while the knock request is pending. stripped_room_state = ( + # TODO: Implement MSC4311 and use full PDUs here await self.store.get_stripped_room_state_from_event_context( context, self._room_prejoin_state_types ) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 166a02d7c7e..530b3f33e21 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -554,7 +554,9 @@ async def try_backfill(domains: StrCollection) -> bool: return False - async def send_invite(self, target_host: str, event: EventBase) -> EventBase: + async def send_invite( + self, target_host: str, event: EventBase, context: EventContext + ) -> EventBase: """Sends the invite to the remote server for signing. Invites must be signed by the invitee's server before distribution. @@ -563,8 +565,8 @@ async def send_invite(self, target_host: str, event: EventBase) -> EventBase: pdu = await self.federation_client.send_invite( destination=target_host, room_id=event.room_id, - event_id=event.event_id, pdu=event, + context=context, ) except RequestSendFailed: raise SynapseError(502, f"Can't connect to server {target_host}") diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py index 4032c7eca97..7eaa8e85328 100644 --- a/synapse/handlers/message.py +++ b/synapse/handlers/message.py @@ -2087,7 +2087,7 @@ async def persist_and_notify_client_events( # to get them to sign the event. returned_invite = await federation_handler.send_invite( - invitee.domain, event + invitee.domain, event, context ) event.unsigned.pop("room_state", None) diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py index cc79b8042bd..f6ba0f27b09 100644 --- a/synapse/storage/databases/main/events_worker.py +++ b/synapse/storage/databases/main/events_worker.py @@ -1137,19 +1137,46 @@ async def get_stripped_room_state_from_event_context( filter = StateFilter.from_types(types) else: filter = state_keys_to_include - selected_state_ids = await context.get_current_state_ids(filter) + + selected_state_ids = await self.get_stripped_room_state_ids_from_event_context( + context, filter + ) + + state_to_include = await self.get_events(selected_state_ids) + + return [strip_event(e) for e in state_to_include.values()] + + async def get_stripped_room_state_ids_from_event_context( + self, + context: EventContext, + state_keys_to_include: StateFilter, + ) -> list[str]: + """ + Retrieve the stripped state IDs for an event, given an event context to retrieve state + from as well as the state types to include. Optionally, include the membership + events from a specific user. + + "Stripped" state means that only the `type`, `state_key`, `content` and `sender` keys + are included from each state event. + + Args: + context: The event context to retrieve state of the room from. + state_keys_to_include: The state events to include, for each event type. + + Returns: + A list of event_ids, each representing the stripped state event to include for this event + """ + selected_state_ids = await context.get_current_state_ids(state_keys_to_include) # We know this event is not an outlier, so this must be # non-None. assert selected_state_ids is not None - # Confusingly, get_current_state_events may return events that are discarded by - # the filter, if they're in context._state_delta_due_to_event. Strip these away. - selected_state_ids = filter.filter_state(selected_state_ids) - - state_to_include = await self.get_events(selected_state_ids.values()) + # Confusingly, `get_current_state_ids` may return events that are discarded by + # the filter, if they're in `context._state_delta_due_to_event`. Strip these away. + selected_state_ids = state_keys_to_include.filter_state(selected_state_ids) - return [strip_event(e) for e in state_to_include.values()] + return list(selected_state_ids.values()) def _maybe_start_fetch_thread(self) -> None: """Starts an event fetch thread if we are not yet at the maximum number.""" From f96c0086f7604a6b4cd988f3b785f87c57f73ce6 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 4 May 2026 16:03:57 -0500 Subject: [PATCH 06/23] Invite event should be stripped when included in `invite_state` --- synapse/rest/client/sync.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/synapse/rest/client/sync.py b/synapse/rest/client/sync.py index c3cf0dc3c4d..5b73d616779 100644 --- a/synapse/rest/client/sync.py +++ b/synapse/rest/client/sync.py @@ -34,6 +34,7 @@ SerializeEventConfig, format_event_for_client_v2_without_room_id, format_event_raw, + strip_event, ) from synapse.handlers.presence import format_user_presence_state from synapse.handlers.sliding_sync import SlidingSyncConfig, SlidingSyncResult @@ -460,7 +461,10 @@ async def encode_invited( invited_state = [] invited_state = list(invited_state) - invited_state.append(invite) + # Add the invite itself + # + # FIXME: Doesn't seem to be in the spec + invited_state.append(strip_event(room.invite)) invited[room.room_id] = {"invite_state": {"events": invited_state}} return invited From a088aa8089ffdfbf09d9683a7665513b26c89979 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 4 May 2026 17:54:06 -0500 Subject: [PATCH 07/23] Sanitize `invite_room_state` received over federation --- rust/src/room_versions.rs | 33 ++++++ synapse/events/utils.py | 9 ++ synapse/federation/federation_client.py | 9 ++ synapse/federation/federation_server.py | 36 +++++- .../federation/transport/server/federation.py | 18 +-- synapse/handlers/federation.py | 106 +++++++++++++++++- synapse/synapse_rust/room_versions.pyi | 10 ++ 7 files changed, 210 insertions(+), 11 deletions(-) diff --git a/rust/src/room_versions.rs b/rust/src/room_versions.rs index dbc962174dd..2dc73501948 100644 --- a/rust/src/room_versions.rs +++ b/rust/src/room_versions.rs @@ -157,6 +157,21 @@ pub struct RoomVersion { /// This is similar to how doubly-linked lists can potentially not refer to previous items correctly /// without verifying the list's integrity, but doing it on every insert is too expensive. pub msc4242_state_dags: bool, + /// Whether the `m.room.create` event is required in the + /// `invite_state`/`knock_state` and `invite_room_state`/`knock_room_state` in the + /// client and federation API's. + /// + /// Also determines whether full PDU's are returned in the + /// `invite_room_state`/`knock_room_state` in the federation API. The client API + /// still uses stripped state. + /// + /// According to MSC4311: + /// > If any of the events are not a PDU, not for the room ID specified, or fail + /// > signature checks, or the `m.room.create` event is missing, the receiving + /// > server MAY respond to invites with a `400 M_MISSING_PARAM` standard Matrix + /// > error (new to the endpoint). For invites to room version 12+ rooms, servers + /// > SHOULD rather than MAY respond to such requests with `400 M_MISSING_PARAM`. + pub msc4311_stripped_state: bool, } const ROOM_VERSION_V1: RoomVersion = RoomVersion { @@ -182,6 +197,7 @@ const ROOM_VERSION_V1: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: false, strict_event_byte_limits_room_versions: false, msc4242_state_dags: false, + msc4311_stripped_state: false, }; const ROOM_VERSION_V2: RoomVersion = RoomVersion { @@ -207,6 +223,7 @@ const ROOM_VERSION_V2: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: false, strict_event_byte_limits_room_versions: false, msc4242_state_dags: false, + msc4311_stripped_state: false, }; const ROOM_VERSION_V3: RoomVersion = RoomVersion { @@ -232,6 +249,7 @@ const ROOM_VERSION_V3: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: false, strict_event_byte_limits_room_versions: false, msc4242_state_dags: false, + msc4311_stripped_state: false, }; const ROOM_VERSION_V4: RoomVersion = RoomVersion { @@ -257,6 +275,7 @@ const ROOM_VERSION_V4: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: false, strict_event_byte_limits_room_versions: false, msc4242_state_dags: false, + msc4311_stripped_state: false, }; const ROOM_VERSION_V5: RoomVersion = RoomVersion { @@ -282,6 +301,7 @@ const ROOM_VERSION_V5: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: false, strict_event_byte_limits_room_versions: false, msc4242_state_dags: false, + msc4311_stripped_state: false, }; const ROOM_VERSION_V6: RoomVersion = RoomVersion { @@ -307,6 +327,7 @@ const ROOM_VERSION_V6: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: false, strict_event_byte_limits_room_versions: false, msc4242_state_dags: false, + msc4311_stripped_state: false, }; const ROOM_VERSION_V7: RoomVersion = RoomVersion { @@ -332,6 +353,7 @@ const ROOM_VERSION_V7: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: false, strict_event_byte_limits_room_versions: false, msc4242_state_dags: false, + msc4311_stripped_state: false, }; const ROOM_VERSION_V8: RoomVersion = RoomVersion { @@ -357,6 +379,7 @@ const ROOM_VERSION_V8: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: false, strict_event_byte_limits_room_versions: false, msc4242_state_dags: false, + msc4311_stripped_state: false, }; const ROOM_VERSION_V9: RoomVersion = RoomVersion { @@ -382,6 +405,7 @@ const ROOM_VERSION_V9: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: false, strict_event_byte_limits_room_versions: false, msc4242_state_dags: false, + msc4311_stripped_state: false, }; const ROOM_VERSION_V10: RoomVersion = RoomVersion { @@ -407,6 +431,7 @@ const ROOM_VERSION_V10: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: false, strict_event_byte_limits_room_versions: false, msc4242_state_dags: false, + msc4311_stripped_state: false, }; /// MSC3389 (Redaction changes for events with a relation) based on room version "10". @@ -433,6 +458,7 @@ const ROOM_VERSION_MSC3389V10: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: false, strict_event_byte_limits_room_versions: true, msc4242_state_dags: false, + msc4311_stripped_state: false, }; /// MSC1767 (Extensible Events) based on room version "10". @@ -459,6 +485,7 @@ const ROOM_VERSION_MSC1767V10: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: false, strict_event_byte_limits_room_versions: false, msc4242_state_dags: false, + msc4311_stripped_state: false, }; /// MSC3757 (Restricting who can overwrite a state event) based on room version "10". @@ -485,6 +512,7 @@ const ROOM_VERSION_MSC3757V10: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: false, strict_event_byte_limits_room_versions: false, msc4242_state_dags: false, + msc4311_stripped_state: false, }; const ROOM_VERSION_V11: RoomVersion = RoomVersion { @@ -510,6 +538,7 @@ const ROOM_VERSION_V11: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: false, strict_event_byte_limits_room_versions: true, // Changed from v10 msc4242_state_dags: false, + msc4311_stripped_state: false, }; /// MSC3757 (Restricting who can overwrite a state event) based on room version "11". @@ -536,6 +565,7 @@ const ROOM_VERSION_MSC3757V11: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: false, strict_event_byte_limits_room_versions: true, msc4242_state_dags: false, + msc4311_stripped_state: false, }; const ROOM_VERSION_HYDRA_V11: RoomVersion = RoomVersion { @@ -561,6 +591,7 @@ const ROOM_VERSION_HYDRA_V11: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: true, // Changed from v11 strict_event_byte_limits_room_versions: true, msc4242_state_dags: false, + msc4311_stripped_state: false, }; const ROOM_VERSION_V12: RoomVersion = RoomVersion { @@ -586,6 +617,7 @@ const ROOM_VERSION_V12: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: true, // Changed from v11 strict_event_byte_limits_room_versions: true, msc4242_state_dags: false, + msc4311_stripped_state: true, }; const ROOM_VERSION_MSC4242V12: RoomVersion = RoomVersion { @@ -611,6 +643,7 @@ const ROOM_VERSION_MSC4242V12: RoomVersion = RoomVersion { msc4291_room_ids_as_hashes: true, strict_event_byte_limits_room_versions: true, msc4242_state_dags: true, + msc4311_stripped_state: true, }; /// Helper class for managing the known room versions, and providing dict-like diff --git a/synapse/events/utils.py b/synapse/events/utils.py index 153352d0110..9f7dbe0c8ea 100644 --- a/synapse/events/utils.py +++ b/synapse/events/utils.py @@ -1052,3 +1052,12 @@ def parse_stripped_state_event(raw_stripped_event: Any) -> StrippedStateEvent | ) return None + + +def serialize_stripped_state_event(stripped_event: StrippedStateEvent) -> JsonDict: + return { + "type": stripped_event.type, + "state_key": stripped_event.state_key, + "sender": stripped_event.sender, + "content": stripped_event.content, + } diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index e8347324d1a..b0d82890516 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -1405,6 +1405,11 @@ async def _do_send_invite( }, ) except HttpResponseException as e: + # TODO: MSC4311: The 400 `M_MISSING_PARAM` error SHOULD be translated to a 5xx + # error by the sending server over the Client-Server API. This is done + # because there's nothing the client can materially do differently to make + # the request succeed. + # If an error is received that is due to an unrecognised endpoint, # fallback to the v1 endpoint if the room uses old-style event IDs. # Otherwise, consider it a legitimate error and raise. @@ -1428,6 +1433,10 @@ async def _do_send_invite( event_id=pdu.event_id, content=pdu.get_pdu_json(time_now), ) + # TODO: MSC4311: The 400 `M_MISSING_PARAM` error SHOULD be translated to a 5xx + # error by the sending server over the Client-Server API. This is done + # because there's nothing the client can materially do differently to make + # the request succeed. return content async def send_leave(self, destinations: Iterable[str], pdu: EventBase) -> None: diff --git a/synapse/federation/federation_server.py b/synapse/federation/federation_server.py index 7018eea1ad5..1097705def8 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -772,8 +772,22 @@ async def on_make_join_request( return {"event": pdu.get_templated_pdu_json(), "room_version": room_version} async def on_invite_request( - self, origin: str, content: JsonDict, room_version_id: str + self, + *, + origin: str, + expected_room_id: str, + expected_event_id: str, + event_json: JsonDict, + room_version_id: str, ) -> dict[str, Any]: + """ + Args: + origin: + expected_room_id: The room ID specified in the + `/_matrix/federation/v1/invite/{roomId}/{eventId}` request that we expect to + match in the actual event itself. + """ + room_version = KNOWN_ROOM_VERSIONS.get(room_version_id) if not room_version: raise SynapseError( @@ -782,9 +796,21 @@ async def on_invite_request( Codes.UNSUPPORTED_ROOM_VERSION, ) - pdu = event_from_pdu_json(content, room_version) + pdu = event_from_pdu_json(event_json, room_version) origin_host, _ = parse_server_name(origin) await self.check_server_matches_acl(origin_host, pdu.room_id) + if pdu.event_id != expected_event_id: + raise SynapseError( + 400, + Codes.INVALID_PARAM, + "Invite event ID must match event ID specified in the federation `/invite` request", + ) + if pdu.room_id != expected_room_id: + raise SynapseError( + 400, + Codes.INVALID_PARAM, + "The room_id specified in the invite event must match room ID specified in the federation `/invite` request", + ) if await self._spam_checker_module_callbacks.should_drop_federated_event(pdu): logger.info( "Federated event contains spam, dropping %s", @@ -797,7 +823,11 @@ async def on_invite_request( errmsg = f"event id {pdu.event_id}: {e}" logger.warning("%s", errmsg) raise SynapseError(403, errmsg, Codes.FORBIDDEN) - ret_pdu = await self.handler.on_invite_request(origin, pdu, room_version) + ret_pdu = await self.handler.on_invite_request( + origin=origin, + event=pdu, + room_version=room_version, + ) time_now = self._clock.time_msec() return {"event": ret_pdu.get_pdu_json(time_now)} diff --git a/synapse/federation/transport/server/federation.py b/synapse/federation/transport/server/federation.py index d783e6da518..7c753ccfa3a 100644 --- a/synapse/federation/transport/server/federation.py +++ b/synapse/federation/transport/server/federation.py @@ -490,7 +490,11 @@ async def on_PUT( # state resolution algorithm, and we don't use that for processing # invites result = await self.handler.on_invite_request( - origin, content, room_version_id=RoomVersions.V1.identifier + origin=origin, + expected_room_id=room_id, + expected_event_id=event_id, + event_json=content, + room_version_id=RoomVersions.V1.identifier, ) # V1 federation API is defined to return a content of `[200, {...}]` @@ -512,9 +516,6 @@ async def on_PUT( room_id: str, event_id: str, ) -> tuple[int, JsonDict]: - # TODO(paul): assert that room_id/event_id parsed from path actually - # match those given in content - room_version = content["room_version"] event = content["event"] invite_room_state = content.get("invite_room_state", []) @@ -523,12 +524,15 @@ async def on_PUT( invite_room_state = [] # Synapse expects invite_room_state to be in unsigned, as it is in v1 - # API - + # API. We will sanitize this inside `on_invite_request(...)` event.setdefault("unsigned", {})["invite_room_state"] = invite_room_state result = await self.handler.on_invite_request( - origin, event, room_version_id=room_version + origin=origin, + expected_room_id=room_id, + expected_event_id=event_id, + event_json=event, + room_version_id=room_version, ) # We only store invite_room_state for internal use, so remove it before diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 530b3f33e21..35da4781f88 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -59,7 +59,15 @@ from synapse.event_auth import validate_event_for_room_version from synapse.events import EventBase from synapse.events.snapshot import EventContext, UnpersistedEventContextBase +from synapse.events.utils import ( + parse_stripped_state_event, + serialize_stripped_state_event, +) from synapse.events.validator import EventValidator +from synapse.federation.federation_base import ( + InvalidEventSignatureError, + event_from_pdu_json, +) from synapse.federation.federation_client import InvalidResponseError from synapse.handlers.pagination import PURGE_PAGINATION_LOCK_NAME from synapse.http.servlet import assert_params_in_dict @@ -1053,7 +1061,11 @@ async def on_make_join_request( return event async def on_invite_request( - self, origin: str, event: EventBase, room_version: RoomVersion + self, + *, + origin: str, + event: EventBase, + room_version: RoomVersion, ) -> EventBase: """We've got an invite event. Process and persist it. Sign it. @@ -1126,6 +1138,98 @@ async def on_invite_request( room_id=event.room_id, room_version=room_version ) + # Validate `invite_room_state` according to MSC4311: + # > If any of the events are not a PDU, not for the room ID specified, or fail + # > signature checks, or the `m.room.create` event is missing, the receiving + # > server MAY respond to invites with a `400 M_MISSING_PARAM` standard Matrix + # > error (new to the endpoint). For invites to room version 12+ rooms, servers + # > SHOULD rather than MAY respond to such requests with `400 M_MISSING_PARAM`. + invite_room_state = event.unsigned.get("invite_room_state") + if invite_room_state is not None and room_version.msc4311_stripped_state: + try: + # Scrutinize JSON values + assert isinstance(invite_room_state, list), ( + "`invite_room_state` must be a list of PDU's" + ) + includes_create_event = False + for raw_stripped_event in invite_room_state: + # Validate PDU + try: + pdu = event_from_pdu_json(raw_stripped_event, room_version) + except Exception as exc: + raise AssertionError( + "Unable to parse one of the `invite_room_state` event's as a PDU" + ) from exc + + if pdu.type == EventTypes.Create: + includes_create_event = True + + # Validate that it's from the same room + assert pdu.room_id == event.room_id, ( + "PDU must be from the room ID specified in the `/invite` request" + ) + # Validate signature/hashes + try: + pdu = await self.federation_client._check_sigs_and_hash( + room_version, pdu + ) + except InvalidEventSignatureError as exc: + raise AssertionError( + "PDU must pass signature/hash checks" + ) from exc + + # Validate `m.room.create` event is included + assert includes_create_event, ( + "`invite_room_state` must include `m.room.create` event" + ) + except Exception as exc: + # FIXME: Reject with 400 `M_MISSING_PARAM` after 2027-01-01. Given Synapse + # claimed to support room version 12 but didn't adhere to this behavior until + # 2026-05-04, we will only warn for now. + logger.warning( + "Continuing anyway but failed to validate `invite_room_state` on invite %s: %s", + event, + exc, + ) + + # With MSC4311: `invite_room_state` over federation can use full PDUs so we need + # to convert them into "stripped state events" so they don't end up being sent + # down to the client. + # + # We do this separate from the validation above as sending full PDU's can happen + # in any room version. + if invite_room_state is not None: + try: + # Scrutinize JSON values + assert isinstance(invite_room_state, list), ( + "`invite_room_state` must be a list" + ) + + new_invite_room_state = [] + for raw_stripped_event in invite_room_state: + # Parse and serialize to strip the events down to only the necessary fields + parsed_stripped_event = parse_stripped_state_event( + raw_stripped_event + ) + if parsed_stripped_event is None: + raise AssertionError("Unable to parse as stripped event") + serialized_stripped_event = serialize_stripped_state_event( + parsed_stripped_event + ) + new_invite_room_state.append(serialized_stripped_event) + + # Replace with our sanitized `invite_room_state` + event.unsigned["invite_room_state"] = new_invite_room_state + except AssertionError as exc: + # We did our best to sanitize but ultimately failed. Leave it as-is for + # the client to interpret. Another valid decision would be to strip it + # from `unsigned` but this is more forwards compatible. + logger.warning( + "Continuing anyway but failed to sanitize `invite_room_state` on invite %s: %s", + event, + exc, + ) + event.internal_metadata.outlier = True event.internal_metadata.out_of_band_membership = True diff --git a/synapse/synapse_rust/room_versions.pyi b/synapse/synapse_rust/room_versions.pyi index 9bbb538f185..593b8a1d340 100644 --- a/synapse/synapse_rust/room_versions.pyi +++ b/synapse/synapse_rust/room_versions.pyi @@ -123,6 +123,16 @@ class RoomVersion: to the create event every time we insert an event would be prohibitively expensive. This is similar to how doubly-linked lists can potentially not refer to previous items correctly without verifying the list's integrity, but doing it on every insert is too expensive.""" + msc4311_stripped_state: bool + """ + Whether the `m.room.create` event is required in the + `invite_state`/`knock_state` and `invite_room_state`/`knock_room_state` in the + client and federation API's. + /// + Also determines whether full PDU's are returned in the + `invite_room_state`/`knock_room_state` in the federation API. The client API + still uses stripped state. + """ class RoomVersions: V1: RoomVersion From 336b68630033309cd376515d3a03973304e0f8b1 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 4 May 2026 17:58:44 -0500 Subject: [PATCH 08/23] Fix test lints --- tests/events/test_auto_accept_invites.py | 12 ++++++------ tests/handlers/test_federation.py | 12 ++++++------ tests/handlers/test_room_member.py | 18 +++++++++--------- tests/handlers/test_room_summary.py | 4 +++- tests/test_visibility.py | 8 +++++--- 5 files changed, 29 insertions(+), 25 deletions(-) diff --git a/tests/events/test_auto_accept_invites.py b/tests/events/test_auto_accept_invites.py index e0ebdf0bcac..f038d5b5068 100644 --- a/tests/events/test_auto_accept_invites.py +++ b/tests/events/test_auto_accept_invites.py @@ -198,9 +198,9 @@ def test_invite_from_remote_user(self) -> None: ) self.get_success( self.handler.on_invite_request( - remote_server, - invite_event, - invite_event.room_version, + origin=remote_server, + event=invite_event, + room_version=invite_event.room_version, ) ) @@ -324,9 +324,9 @@ def test_accept_invite_local_user( ) self.get_success( self.handler.on_invite_request( - remote_server, - invite_event, - invite_event.room_version, + origin=remote_server, + event=invite_event, + room_version=invite_event.room_version, ) ) else: diff --git a/tests/handlers/test_federation.py b/tests/handlers/test_federation.py index dde17858549..6fd01d67d8d 100644 --- a/tests/handlers/test_federation.py +++ b/tests/handlers/test_federation.py @@ -343,18 +343,18 @@ def create_invite() -> EventBase: event = create_invite() self.get_success( self.handler.on_invite_request( - other_server, - event, - event.room_version, + origin=other_server, + event=event, + room_version=event.room_version, ) ) event = create_invite() self.get_failure( self.handler.on_invite_request( - other_server, - event, - event.room_version, + origin=other_server, + event=event, + room_version=event.room_version, ), exc=LimitExceededError, by=0.5, diff --git a/tests/handlers/test_room_member.py b/tests/handlers/test_room_member.py index 3890abdbc83..fbfadd2d51c 100644 --- a/tests/handlers/test_room_member.py +++ b/tests/handlers/test_room_member.py @@ -566,9 +566,9 @@ def test_msc4155_block_invite_remote(self) -> None: f = self.get_failure( self.fed_handler.on_invite_request( - remote_server, - invite_event, - invite_event.room_version, + origin=remote_server, + event=invite_event, + room_version=invite_event.room_version, ), SynapseError, ).value @@ -612,9 +612,9 @@ def test_msc4155_block_invite_remote_server(self) -> None: f = self.get_failure( self.fed_handler.on_invite_request( - remote_server, - invite_event, - invite_event.room_version, + origin=remote_server, + event=invite_event, + room_version=invite_event.room_version, ), SynapseError, ).value @@ -727,9 +727,9 @@ def test_msc4380_block_invite_remote(self) -> None: f = self.get_failure( self.fed_handler.on_invite_request( - remote_server, - invite_event, - invite_event.room_version, + origin=remote_server, + event=invite_event, + room_version=invite_event.room_version, ), SynapseError, ).value diff --git a/tests/handlers/test_room_summary.py b/tests/handlers/test_room_summary.py index ee65cb1afbb..4d19ecce391 100644 --- a/tests/handlers/test_room_summary.py +++ b/tests/handlers/test_room_summary.py @@ -232,7 +232,9 @@ def _poke_fed_invite(self, room_id: str, from_user: str) -> None: } ) self.get_success( - fed_handler.on_invite_request(fed_hostname, event, RoomVersions.V6) + fed_handler.on_invite_request( + origin=fed_hostname, event=event, room_version=RoomVersions.V6 + ) ) def test_simple_space(self) -> None: diff --git a/tests/test_visibility.py b/tests/test_visibility.py index 9a5efbdd399..0654f84351e 100644 --- a/tests/test_visibility.py +++ b/tests/test_visibility.py @@ -608,9 +608,11 @@ def test_out_of_band_invite_rejection(self) -> None: self.get_success( self.hs.get_federation_server().on_invite_request( - self.OTHER_SERVER_NAME, - invite_pdu, - "9", + origin=self.OTHER_SERVER_NAME, + expected_event_id=invite_event_id, + expected_room_id="!room:id", + event_json=invite_pdu, + room_version_id="9", ) ) From 22f4f2004fa140b66dee2216d82dbe646d546c60 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 4 May 2026 18:24:39 -0500 Subject: [PATCH 09/23] Updated comment --- rust/src/room_versions.rs | 12 ++++++++---- synapse/synapse_rust/room_versions.pyi | 21 ++++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/rust/src/room_versions.rs b/rust/src/room_versions.rs index 2dc73501948..7728f02c234 100644 --- a/rust/src/room_versions.rs +++ b/rust/src/room_versions.rs @@ -157,11 +157,11 @@ pub struct RoomVersion { /// This is similar to how doubly-linked lists can potentially not refer to previous items correctly /// without verifying the list's integrity, but doing it on every insert is too expensive. pub msc4242_state_dags: bool, - /// Whether the `m.room.create` event is required in the - /// `invite_state`/`knock_state` and `invite_room_state`/`knock_room_state` in the - /// client and federation API's. + /// Whether the `m.room.create` event is required in + /// `invite_room_state`/`knock_room_state` when receiving invites/knocks over the + /// federation API's. /// - /// Also determines whether full PDU's are returned in the + /// Also determines whether we expect full PDU's in the /// `invite_room_state`/`knock_room_state` in the federation API. The client API /// still uses stripped state. /// @@ -171,6 +171,10 @@ pub struct RoomVersion { /// > server MAY respond to invites with a `400 M_MISSING_PARAM` standard Matrix /// > error (new to the endpoint). For invites to room version 12+ rooms, servers /// > SHOULD rather than MAY respond to such requests with `400 M_MISSING_PARAM`. + /// + /// This does *not* determine whether we should include the `m.room.create` event in + /// stripped state or use full PDU's in stripped state over federation. We should + /// always do this. pub msc4311_stripped_state: bool, } diff --git a/synapse/synapse_rust/room_versions.pyi b/synapse/synapse_rust/room_versions.pyi index 593b8a1d340..0bd56ebda9a 100644 --- a/synapse/synapse_rust/room_versions.pyi +++ b/synapse/synapse_rust/room_versions.pyi @@ -125,13 +125,24 @@ class RoomVersion: without verifying the list's integrity, but doing it on every insert is too expensive.""" msc4311_stripped_state: bool """ - Whether the `m.room.create` event is required in the - `invite_state`/`knock_state` and `invite_room_state`/`knock_room_state` in the - client and federation API's. - /// - Also determines whether full PDU's are returned in the + Whether the `m.room.create` event is required in + `invite_room_state`/`knock_room_state` when receiving invites/knocks over the + federation API's. + + Also determines whether we expect full PDU's in the `invite_room_state`/`knock_room_state` in the federation API. The client API still uses stripped state. + + According to MSC4311: + > If any of the events are not a PDU, not for the room ID specified, or fail + > signature checks, or the `m.room.create` event is missing, the receiving + > server MAY respond to invites with a `400 M_MISSING_PARAM` standard Matrix + > error (new to the endpoint). For invites to room version 12+ rooms, servers + > SHOULD rather than MAY respond to such requests with `400 M_MISSING_PARAM`. + + This does *not* determine whether we should include the `m.room.create` event in + stripped state or use full PDU's in stripped state over federation. We should + always do this. """ class RoomVersions: From 7e379e75e978ec21edd708c81513699882085822 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 21 May 2026 18:51:30 -0500 Subject: [PATCH 10/23] Make the TODO more obvious --- synapse/federation/federation_client.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index 36245f665c0..178e1e7e0ac 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -1427,16 +1427,19 @@ async def _do_send_invite( # Didn't work, try v1 API. # Note the v1 API returns a tuple of `(200, content)` - _, content = await self.transport_layer.send_invite_v1( - destination=destination, - room_id=pdu.room_id, - event_id=pdu.event_id, - content=pdu.get_pdu_json(time_now), - ) - # TODO: MSC4311: The 400 `M_MISSING_PARAM` error SHOULD be translated to a 5xx - # error by the sending server over the Client-Server API. This is done - # because there's nothing the client can materially do differently to make - # the request succeed. + try: + _, content = await self.transport_layer.send_invite_v1( + destination=destination, + room_id=pdu.room_id, + event_id=pdu.event_id, + content=pdu.get_pdu_json(time_now), + ) + except HttpResponseException as e: + # TODO: MSC4311: The 400 `M_MISSING_PARAM` error SHOULD be translated to a 5xx + # error by the sending server over the Client-Server API. This is done + # because there's nothing the client can materially do differently to make + # the request succeed. + raise e return content async def send_leave(self, destinations: Iterable[str], pdu: EventBase) -> None: From 92d0d8bdaceb2e7a969e7b26dea8bc33dcaab0dc Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 21 May 2026 18:53:03 -0500 Subject: [PATCH 11/23] Placeholder arg docstring --- synapse/federation/federation_server.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/synapse/federation/federation_server.py b/synapse/federation/federation_server.py index 1a659777fe4..f3e9b855d24 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -781,6 +781,11 @@ async def on_invite_request( expected_room_id: The room ID specified in the `/_matrix/federation/v1/invite/{roomId}/{eventId}` request that we expect to match in the actual event itself. + expected_event_id: The event ID specified in the + `/_matrix/federation/v1/invite/{roomId}/{eventId}` request that we expect to + match in the actual event itself. + event_json: + room_version_id: """ room_version = KNOWN_ROOM_VERSIONS.get(room_version_id) From 76b49051982ec6042b34e4b30d6270b707c0ab25 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 21 May 2026 19:00:24 -0500 Subject: [PATCH 12/23] Better logic/reasoning --- synapse/handlers/federation.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 91f87972879..df49d23b92c 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -1155,12 +1155,16 @@ async def on_invite_request( # > server MAY respond to invites with a `400 M_MISSING_PARAM` standard Matrix # > error (new to the endpoint). For invites to room version 12+ rooms, servers # > SHOULD rather than MAY respond to such requests with `400 M_MISSING_PARAM`. + # + # FIXME: Always validate for all room versions after 2027-01-01. Given Synapse + # claimed to support room version 12 but didn't adhere to this behavior until + # 2026-05-04, we will skip for now. invite_room_state = event.unsigned.get("invite_room_state") - if invite_room_state is not None and room_version.msc4311_stripped_state: + if room_version.msc4311_stripped_state: try: # Scrutinize JSON values assert isinstance(invite_room_state, list), ( - "`invite_room_state` must be a list of PDU's" + "`invite_room_state` must be a list of PDU's that includes the `m.room.create` event" ) includes_create_event = False for raw_stripped_event in invite_room_state: @@ -1205,7 +1209,7 @@ async def on_invite_request( # With MSC4311: `invite_room_state` over federation can use full PDUs so we need # to convert them into "stripped state events" so they don't end up being sent - # down to the client. + # down to the client as full PDU's. # # We do this separate from the validation above as sending full PDU's can happen # in any room version. From 5103f1b03a19712cfdfd1137bac5d7b63b8efa06 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 21 May 2026 19:06:18 -0500 Subject: [PATCH 13/23] Better comment flow --- synapse/handlers/federation.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index df49d23b92c..cf0fbc2b2d9 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -1156,9 +1156,8 @@ async def on_invite_request( # > error (new to the endpoint). For invites to room version 12+ rooms, servers # > SHOULD rather than MAY respond to such requests with `400 M_MISSING_PARAM`. # - # FIXME: Always validate for all room versions after 2027-01-01. Given Synapse - # claimed to support room version 12 but didn't adhere to this behavior until - # 2026-05-04, we will skip for now. + # FIXME: Apply this validation for all room versions after 2027-01-01 (to allow + # some time for the ecosystem to adapt and support MSC4311). invite_room_state = event.unsigned.get("invite_room_state") if room_version.msc4311_stripped_state: try: @@ -1200,7 +1199,7 @@ async def on_invite_request( except Exception as exc: # FIXME: Reject with 400 `M_MISSING_PARAM` after 2027-01-01. Given Synapse # claimed to support room version 12 but didn't adhere to this behavior until - # 2026-05-04, we will only warn for now. + # 2026-06-01, we will only warn for now. logger.warning( "Continuing anyway but failed to validate `invite_room_state` on invite %s: %s", event, From 5c1f4ca55e1a0a9763172c1e9980dc0d90ae926f Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 21 May 2026 19:09:39 -0500 Subject: [PATCH 14/23] Use `FIXME(MSC4311)` prefix --- synapse/handlers/federation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index cf0fbc2b2d9..f4cb3253ab3 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -1156,7 +1156,7 @@ async def on_invite_request( # > error (new to the endpoint). For invites to room version 12+ rooms, servers # > SHOULD rather than MAY respond to such requests with `400 M_MISSING_PARAM`. # - # FIXME: Apply this validation for all room versions after 2027-01-01 (to allow + # FIXME(MSC4311): Apply this validation for all room versions after 2027-01-01 (to allow # some time for the ecosystem to adapt and support MSC4311). invite_room_state = event.unsigned.get("invite_room_state") if room_version.msc4311_stripped_state: @@ -1197,7 +1197,7 @@ async def on_invite_request( "`invite_room_state` must include `m.room.create` event" ) except Exception as exc: - # FIXME: Reject with 400 `M_MISSING_PARAM` after 2027-01-01. Given Synapse + # FIXME(MSC4311): Reject with 400 `M_MISSING_PARAM` after 2027-01-01. Given Synapse # claimed to support room version 12 but didn't adhere to this behavior until # 2026-06-01, we will only warn for now. logger.warning( From 374c4c5c864cdc1ec0e787c1a579c344a15ec1f7 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 21 May 2026 19:10:56 -0500 Subject: [PATCH 15/23] Mark down create event after we verify it's valid --- synapse/handlers/federation.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index f4cb3253ab3..17637c6e9ad 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -1175,9 +1175,6 @@ async def on_invite_request( "Unable to parse one of the `invite_room_state` event's as a PDU" ) from exc - if pdu.type == EventTypes.Create: - includes_create_event = True - # Validate that it's from the same room assert pdu.room_id == event.room_id, ( "PDU must be from the room ID specified in the `/invite` request" @@ -1192,6 +1189,13 @@ async def on_invite_request( "PDU must pass signature/hash checks" ) from exc + # Mark down whether we saw the create event which we will validate just below + # + # We do this after the above checks to make sure it's a valid event + # from this room. + if pdu.type == EventTypes.Create: + includes_create_event = True + # Validate `m.room.create` event is included assert includes_create_event, ( "`invite_room_state` must include `m.room.create` event" From ffe5c4b25619f26706a3ebaa4a4182f5bbd30df0 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 21 May 2026 19:14:03 -0500 Subject: [PATCH 16/23] Small comment to explain (we also explain above) --- synapse/federation/federation_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index 178e1e7e0ac..4fd16228d69 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -1399,6 +1399,7 @@ async def _do_send_invite( "event": pdu.get_pdu_json(time_now), "room_version": room_version.identifier, "invite_room_state": [ + # Use full PDU's according to MSC4311 state_event.get_pdu_json(time_now) for state_event in state_events.values() ], From 2b900b443c1064b4014a1ed8d29587602e2f4fc5 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 22 May 2026 18:07:54 -0500 Subject: [PATCH 17/23] Better `invite_room_state` scrutiny --- synapse/federation/federation_client.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index 4fd16228d69..c1fe7d71bbc 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -1352,6 +1352,12 @@ async def _do_send_invite( """Actually sends the invite, first trying v2 API and falling back to v1 API if necessary. + Args: + destination: + pdu: Invite event. This function assumes that `unsigned.invite_room_state` is filled in. + context: + room_version: + Returns: The event as a dict as returned by the remote server @@ -1366,11 +1372,21 @@ async def _do_send_invite( # PDU's # # First get all of the expected stripped state events that should be included. - # We will derive these from the `unsigned` part of the PDU but this doesn't - # include any event ID information so we need to look it up based on the state - # at the time of the invite. + # We will derive these from the `unsigned` part of the PDU which already has + # `invite_room_state` calculated but this doesn't include any event ID + # information so we need to look it up based on the state at the time of the + # invite. + # + # It would also be reasonable to use `hs.config.api.room_prejoin_state` but we + # might as well read from this source of truth to exactly match. stripped_state_types = [] - for raw_stripped_event in pdu.unsigned.get("invite_room_state", []): + unsigned_invite_room_state = pdu.unsigned.get("invite_room_state") + # Scrutinize untyped values + assert isinstance(unsigned_invite_room_state, list), ( + f"Expected `unsigned.invite_room_state` on {pdu.event_id} to exist and be a list (found {unsigned_invite_room_state})." + "This is a Synapse programming error." + ) + for raw_stripped_event in unsigned_invite_room_state: stripped_state_event = parse_stripped_state_event(raw_stripped_event) # Since this is our own invite, it should always be well-formed assert stripped_state_event is not None, ( From 5e12cb89bb53a40881b1116181f25f9cbaa32c36 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 22 May 2026 18:08:26 -0500 Subject: [PATCH 18/23] Fix `events` typo --- synapse/federation/federation_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index c1fe7d71bbc..4294ef25855 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -1390,7 +1390,7 @@ async def _do_send_invite( stripped_state_event = parse_stripped_state_event(raw_stripped_event) # Since this is our own invite, it should always be well-formed assert stripped_state_event is not None, ( - "Unable to parse one of the evnts from the `invite_room_state` as a stripped state event" + "Unable to parse one of the events from the `invite_room_state` as a stripped state event" ) stripped_state_types.append( (stripped_state_event.type, stripped_state_event.state_key) From ff533dfad24189fbfe292d5ae0ebf59207bd8b81 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 22 May 2026 18:10:36 -0500 Subject: [PATCH 19/23] Event ID context --- synapse/federation/federation_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index 4294ef25855..4c66d0d323e 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -1383,14 +1383,14 @@ async def _do_send_invite( unsigned_invite_room_state = pdu.unsigned.get("invite_room_state") # Scrutinize untyped values assert isinstance(unsigned_invite_room_state, list), ( - f"Expected `unsigned.invite_room_state` on {pdu.event_id} to exist and be a list (found {unsigned_invite_room_state})." + f"Expected `unsigned.invite_room_state` on event_id={pdu.event_id} to exist and be a list (found {unsigned_invite_room_state})." "This is a Synapse programming error." ) for raw_stripped_event in unsigned_invite_room_state: stripped_state_event = parse_stripped_state_event(raw_stripped_event) # Since this is our own invite, it should always be well-formed assert stripped_state_event is not None, ( - "Unable to parse one of the events from the `invite_room_state` as a stripped state event" + f"Unable to parse one of the events from the `unsigned.invite_room_state` on event_id={pdu.event_id} as a stripped state event." ) stripped_state_types.append( (stripped_state_event.type, stripped_state_event.state_key) From de78d9eb467633a4f7542f3fa8c16ef635ef5334 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 22 May 2026 18:16:07 -0500 Subject: [PATCH 20/23] Simplify logic to just use `hs.config.api.room_prejoin_state` instead of deriving from `invite_room_state` We don't even calculate `knock_room_state` to do the same pattern and it's a bunch of extra complexity. --- synapse/federation/federation_client.py | 31 +++---------------------- synapse/federation/federation_server.py | 31 ++++++++++++++++++------- 2 files changed, 25 insertions(+), 37 deletions(-) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index 4c66d0d323e..f68452a12a0 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -146,6 +146,7 @@ def __init__(self, hs: "HomeServer"): self.server_name = hs.hostname self.signing_key = hs.signing_key + self._room_prejoin_state_types = hs.config.api.room_prejoin_state # Cache mapping `event_id` to a tuple of the event itself and the `pull_origin` # (which server we pulled the event from) @@ -1354,7 +1355,7 @@ async def _do_send_invite( Args: destination: - pdu: Invite event. This function assumes that `unsigned.invite_room_state` is filled in. + pdu: Invite event context: room_version: @@ -1371,35 +1372,9 @@ async def _do_send_invite( # MSC4311: For the federation API, format events in `invite_room_state` as full # PDU's # - # First get all of the expected stripped state events that should be included. - # We will derive these from the `unsigned` part of the PDU which already has - # `invite_room_state` calculated but this doesn't include any event ID - # information so we need to look it up based on the state at the time of the - # invite. - # - # It would also be reasonable to use `hs.config.api.room_prejoin_state` but we - # might as well read from this source of truth to exactly match. - stripped_state_types = [] - unsigned_invite_room_state = pdu.unsigned.get("invite_room_state") - # Scrutinize untyped values - assert isinstance(unsigned_invite_room_state, list), ( - f"Expected `unsigned.invite_room_state` on event_id={pdu.event_id} to exist and be a list (found {unsigned_invite_room_state})." - "This is a Synapse programming error." - ) - for raw_stripped_event in unsigned_invite_room_state: - stripped_state_event = parse_stripped_state_event(raw_stripped_event) - # Since this is our own invite, it should always be well-formed - assert stripped_state_event is not None, ( - f"Unable to parse one of the events from the `unsigned.invite_room_state` on event_id={pdu.event_id} as a stripped state event." - ) - stripped_state_types.append( - (stripped_state_event.type, stripped_state_event.state_key) - ) - # Find the full events based on the state at the time of the invite - state_filter = StateFilter.from_types(stripped_state_types) state_ids = await self.store.get_stripped_room_state_ids_from_event_context( - context, state_filter + context, self._room_prejoin_state_types ) state_events = await self.store.get_events(state_ids) assert set(state_ids) == set(state_events.keys()), ( diff --git a/synapse/federation/federation_server.py b/synapse/federation/federation_server.py index f3e9b855d24..49263b91c74 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -57,6 +57,7 @@ from synapse.crypto.event_signing import compute_event_signature from synapse.events import EventBase from synapse.events.snapshot import EventPersistencePair +from synapse.events.utils import parse_stripped_state_event from synapse.federation.federation_base import ( FederationBase, InvalidEventSignatureError, @@ -88,6 +89,7 @@ from synapse.storage.databases.main.roommember import extract_heroes_from_room_summary from synapse.storage.roommember import MemberSummary from synapse.types import JsonDict, StateMap, UserID, get_domain_from_id +from synapse.types.state import StateFilter from synapse.util import unwrapFirstError from synapse.util.async_helpers import Linearizer, concurrently_execute, gather_results from synapse.util.caches.response_cache import ResponseCache @@ -987,20 +989,31 @@ async def on_send_knock_request( Returns: The stripped room state. """ + time_now = self._clock.time_msec() + _, context = await self._on_send_membership_event( origin, content, Membership.KNOCK, room_id ) - # Retrieve stripped state events from the room and send them back to the remote - # server. This will allow the remote server's clients to display information - # related to the room while the knock request is pending. - stripped_room_state = ( - # TODO: Implement MSC4311 and use full PDUs here - await self.store.get_stripped_room_state_from_event_context( - context, self._room_prejoin_state_types - ) + # MSC4311: For the federation API, format events in `knock_room_state` as full + # PDU's + # + # Find the full events based on the state at the time of the knock + state_ids = await self.store.get_stripped_room_state_ids_from_event_context( + context, self._room_prejoin_state_types + ) + state_events = await self.store.get_events(state_ids) + assert set(state_ids) == set(state_events.keys()), ( + "We should have all events available that were set as stripped state." ) - return {"knock_room_state": stripped_room_state} + + return { + "knock_room_state": [ + # Use full PDU's according to MSC4311 + state_event.get_pdu_json(time_now) + for state_event in state_events.values() + ] + } async def _on_send_membership_event( self, origin: str, content: JsonDict, membership_type: str, room_id: str From 4598a43364887144d2cfa40f5441507a888e2d4e Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 22 May 2026 19:49:38 -0500 Subject: [PATCH 21/23] `_parse_stripped_room_state` --- synapse/federation/federation_client.py | 2 - synapse/federation/federation_server.py | 2 - synapse/handlers/federation.py | 205 +++++++++++++----------- 3 files changed, 111 insertions(+), 98 deletions(-) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index f68452a12a0..0bd0b17cf85 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -61,7 +61,6 @@ ) from synapse.events import EventBase, builder, make_event_from_dict from synapse.events.snapshot import EventContext -from synapse.events.utils import parse_stripped_state_event from synapse.federation.federation_base import ( FederationBase, InvalidEventSignatureError, @@ -79,7 +78,6 @@ UserID, get_domain_from_id, ) -from synapse.types.state import StateFilter from synapse.util.async_helpers import concurrently_execute from synapse.util.caches.expiringcache import ExpiringCache from synapse.util.duration import Duration diff --git a/synapse/federation/federation_server.py b/synapse/federation/federation_server.py index 49263b91c74..f2084a1bf7c 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -57,7 +57,6 @@ from synapse.crypto.event_signing import compute_event_signature from synapse.events import EventBase from synapse.events.snapshot import EventPersistencePair -from synapse.events.utils import parse_stripped_state_event from synapse.federation.federation_base import ( FederationBase, InvalidEventSignatureError, @@ -89,7 +88,6 @@ from synapse.storage.databases.main.roommember import extract_heroes_from_room_summary from synapse.storage.roommember import MemberSummary from synapse.types import JsonDict, StateMap, UserID, get_domain_from_id -from synapse.types.state import StateFilter from synapse.util import unwrapFirstError from synapse.util.async_helpers import Linearizer, concurrently_execute, gather_results from synapse.util.caches.response_cache import ResponseCache diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 17637c6e9ad..57436ffeffa 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -57,7 +57,7 @@ from synapse.api.room_versions import KNOWN_ROOM_VERSIONS, RoomVersion from synapse.crypto.event_signing import compute_event_signature from synapse.event_auth import validate_event_for_room_version -from synapse.events import EventBase +from synapse.events import EventBase, StrippedStateEvent from synapse.events.snapshot import EventContext, UnpersistedEventContextBase from synapse.events.utils import ( parse_stripped_state_event, @@ -119,6 +119,11 @@ """ +class StrippedRoomStateType(enum.Enum): + INVITE = "invite_room_state" + KNOCK = "knock_room_state" + + # TODO: We can refactor this away now that there is only one backfill point again class _BackfillPointType(Enum): # a regular backwards extremity (ie, an event which we don't yet have, but which @@ -1071,6 +1076,87 @@ async def on_make_join_request( await self._event_auth_handler.check_auth_rules_from_context(event) return event + async def _parse_stripped_room_state( + self, + stripped_room_state_type: StrippedRoomStateType, + event: EventBase, + room_version: RoomVersion, + ) -> list[StrippedStateEvent]: + """ + Parse and validate `invite_room_state`/`knock_room_state` according to the + Matrix spec (c.f. MSC4311). + + > If any of the events are not a PDU, not for the room ID specified, or fail + > signature checks, or the `m.room.create` event is missing, the receiving + > server MAY respond to invites with a `400 M_MISSING_PARAM` standard Matrix + > error (new to the endpoint). For invites to room version 12+ rooms, servers + > SHOULD rather than MAY respond to such requests with `400 M_MISSING_PARAM`. + + We refer to `invite_room_state`/`knock_room_state` as `stripped_room_state` but + the events contained within can be full PDU's or stripped state events (older + version of the Matrix spec). + + Returns: + A list of parsed `StrippedStateEvent` + + Raises: + `TypeError`/`ValueError` when the stripped room state is invalid + """ + stripped_room_state = event.unsigned.get(stripped_room_state_type.value) + + # Scrutinize JSON values + if not isinstance(stripped_room_state, list): + raise TypeError( + f"`{stripped_room_state_type.value}` must be a list of PDU's that includes the `m.room.create` event" + ) + + parsed_stripped_room_state = [] + includes_create_event = False + for raw_stripped_event in stripped_room_state: + # Validate PDU + try: + pdu = event_from_pdu_json(raw_stripped_event, room_version) + except Exception as exc: + raise ValueError( + f"Unable to parse one of the `{stripped_room_state_type.value}` event's as a PDU" + ) from exc + + # Validate that it's from the same room + if pdu.room_id != event.room_id: + raise ValueError( + f"PDU from {stripped_room_state_type.value} must be from the room ID specified in the `/invite` request" + ) + # Validate signature/hashes + try: + pdu = await self.federation_client._check_sigs_and_hash( + room_version, pdu + ) + except InvalidEventSignatureError as exc: + raise ValueError( + f"PDU from {stripped_room_state_type.value} must pass signature/hash checks" + ) from exc + + # Mark down whether we saw the create event which we will validate just below + # + # We do this after the above checks to make sure it's a valid event + # from this room. + if pdu.type == EventTypes.Create: + includes_create_event = True + + # Parse the stripped events to ensure it has all of the fields necessary + parsed_stripped_event = parse_stripped_state_event(raw_stripped_event) + if parsed_stripped_event is None: + raise ValueError("Unable to parse as stripped event") + parsed_stripped_room_state.append(parsed_stripped_event) + + # Validate `m.room.create` event is included + if not includes_create_event: + raise ValueError( + f"`{stripped_room_state_type.value}` must include `m.room.create` event" + ) + + return parsed_stripped_room_state + async def on_invite_request( self, *, @@ -1149,104 +1235,35 @@ async def on_invite_request( room_id=event.room_id, room_version=room_version ) - # Validate `invite_room_state` according to MSC4311: - # > If any of the events are not a PDU, not for the room ID specified, or fail - # > signature checks, or the `m.room.create` event is missing, the receiving - # > server MAY respond to invites with a `400 M_MISSING_PARAM` standard Matrix - # > error (new to the endpoint). For invites to room version 12+ rooms, servers - # > SHOULD rather than MAY respond to such requests with `400 M_MISSING_PARAM`. - # - # FIXME(MSC4311): Apply this validation for all room versions after 2027-01-01 (to allow - # some time for the ecosystem to adapt and support MSC4311). - invite_room_state = event.unsigned.get("invite_room_state") - if room_version.msc4311_stripped_state: - try: - # Scrutinize JSON values - assert isinstance(invite_room_state, list), ( - "`invite_room_state` must be a list of PDU's that includes the `m.room.create` event" - ) - includes_create_event = False - for raw_stripped_event in invite_room_state: - # Validate PDU - try: - pdu = event_from_pdu_json(raw_stripped_event, room_version) - except Exception as exc: - raise AssertionError( - "Unable to parse one of the `invite_room_state` event's as a PDU" - ) from exc - - # Validate that it's from the same room - assert pdu.room_id == event.room_id, ( - "PDU must be from the room ID specified in the `/invite` request" - ) - # Validate signature/hashes - try: - pdu = await self.federation_client._check_sigs_and_hash( - room_version, pdu - ) - except InvalidEventSignatureError as exc: - raise AssertionError( - "PDU must pass signature/hash checks" - ) from exc - - # Mark down whether we saw the create event which we will validate just below - # - # We do this after the above checks to make sure it's a valid event - # from this room. - if pdu.type == EventTypes.Create: - includes_create_event = True - - # Validate `m.room.create` event is included - assert includes_create_event, ( - "`invite_room_state` must include `m.room.create` event" - ) - except Exception as exc: - # FIXME(MSC4311): Reject with 400 `M_MISSING_PARAM` after 2027-01-01. Given Synapse - # claimed to support room version 12 but didn't adhere to this behavior until - # 2026-06-01, we will only warn for now. + # Parse/validate `invite_room_state` + try: + stripped_room_state = await self._parse_stripped_room_state( + StrippedRoomStateType.INVITE, event, room_version + ) + # Replace with our sanitized `invite_room_state` + event.unsigned["invite_room_state"] = [ + serialize_stripped_state_event(stripped_state_event) + for stripped_state_event in stripped_room_state + ] + except Exception as exc: + # FIXME(MSC4311): Apply this validation for all room versions after 2027-06-01 (to allow + # some time for the ecosystem to adapt and support MSC4311). + if room_version.msc4311_stripped_state: + # FIXME(MSC4311): Instead of logging, reject with 400 `M_MISSING_PARAM` + # after 2027-06-01. Given Synapse claimed to support room version 12 but + # didn't adhere to this behavior until 2026-06-01, we will only warn for + # now. logger.warning( - "Continuing anyway but failed to validate `invite_room_state` on invite %s: %s", + "Continuing anyway but failed to validate `invite_room_state` on invite %s (room_version=%s): %s", event, + room_version, exc, ) - # With MSC4311: `invite_room_state` over federation can use full PDUs so we need - # to convert them into "stripped state events" so they don't end up being sent - # down to the client as full PDU's. - # - # We do this separate from the validation above as sending full PDU's can happen - # in any room version. - if invite_room_state is not None: - try: - # Scrutinize JSON values - assert isinstance(invite_room_state, list), ( - "`invite_room_state` must be a list" - ) - - new_invite_room_state = [] - for raw_stripped_event in invite_room_state: - # Parse and serialize to strip the events down to only the necessary fields - parsed_stripped_event = parse_stripped_state_event( - raw_stripped_event - ) - if parsed_stripped_event is None: - raise AssertionError("Unable to parse as stripped event") - serialized_stripped_event = serialize_stripped_state_event( - parsed_stripped_event - ) - new_invite_room_state.append(serialized_stripped_event) - - # Replace with our sanitized `invite_room_state` - event.unsigned["invite_room_state"] = new_invite_room_state - except AssertionError as exc: - # We did our best to sanitize but ultimately failed. Leave it as-is for - # the client to interpret. Another valid decision would be to strip it - # from `unsigned` but this is more forwards compatible. - logger.warning( - "Continuing anyway but failed to sanitize `invite_room_state` on invite %s: %s", - event, - exc, - ) + # We did our best to sanitize `event.unsigned["invite_room_state"]` but + # ultimately failed. Leave it as-is for the client to interpret. Another + # valid decision would be to strip it from `unsigned` but this is more + # forwards compatible. event.internal_metadata.outlier = True event.internal_metadata.out_of_band_membership = True From fbff6859e3d10184ef0136f60041cdea9aacc94c Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 22 May 2026 20:14:43 -0500 Subject: [PATCH 22/23] `_minimal_parse_stripped_room_state` --- synapse/handlers/federation.py | 119 +++++++++++++++++++++++++-------- 1 file changed, 91 insertions(+), 28 deletions(-) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 57436ffeffa..4fed3b3277c 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -30,6 +30,7 @@ from typing import ( TYPE_CHECKING, AbstractSet, + Any, Iterable, ) @@ -119,11 +120,6 @@ """ -class StrippedRoomStateType(enum.Enum): - INVITE = "invite_room_state" - KNOCK = "knock_room_state" - - # TODO: We can refactor this away now that there is only one backfill point again class _BackfillPointType(Enum): # a regular backwards extremity (ie, an event which we don't yet have, but which @@ -907,15 +903,45 @@ async def do_knock( # This is a bit of a hack and is cribbing off of invites. Basically we # store the room state here and retrieve it again when this event appears # in the invitee's sync stream. It is stripped out for all other local users. - stripped_room_state = knock_response.get("knock_room_state") - - if stripped_room_state is None: - raise KeyError("Missing 'knock_room_state' field in send_knock response") - - if not isinstance(stripped_room_state, list): - raise TypeError("'knock_room_state' has wrong type") + # + # Parse/validate `knock_room_state` + try: + stripped_room_state = await self._parse_stripped_room_state( + stripped_room_state=knock_response.get("knock_room_state"), + room_id=event.room_id, + room_version=event_format_version, + ) + # Replace with our sanitized `knock_room_state` + event.unsigned["knock_room_state"] = [ + serialize_stripped_state_event(stripped_state_event) + for stripped_state_event in stripped_room_state + ] + except Exception as exc: + # FIXME(MSC4311): Apply this validation for all room versions after 2027-06-01 (to allow + # some time for the ecosystem to adapt and support MSC4311). + if event_format_version.msc4311_stripped_state: + # FIXME(MSC4311): Instead of logging, reject with 400 `M_MISSING_PARAM` + # after 2027-06-01. Given Synapse claimed to support room version 12 but + # didn't adhere to this behavior until 2026-06-01, we will only warn for + # now. + logger.warning( + "Continuing anyway but failed to validate `knock_room_state` on knock %s (room_version=%s): %s", + event, + event_format_version, + exc, + ) - event.unsigned["knock_room_state"] = stripped_room_state + # FIXME(MSC4311): Remove this whole block after we always enforce the + # validation above. The only reason this is here is because the validation + # can fail for non-compliant servers but we should still use stripped state. + stripped_room_state = self._minimal_parse_stripped_room_state( + stripped_room_state=event.unsigned.get("knock_room_state"), + ) + # Replace with our sanitized `knock_room_state` + event.unsigned["knock_room_state"] = [ + serialize_stripped_state_event(stripped_state_event) + for stripped_state_event in stripped_room_state + ] context = EventContext.for_outlier(self._storage_controllers) stream_id = await self._federation_event_handler.persist_events_and_notify( @@ -1076,10 +1102,35 @@ async def on_make_join_request( await self._event_auth_handler.check_auth_rules_from_context(event) return event + def _minimal_parse_stripped_room_state( + self, + *, + stripped_room_state: Any, + ) -> list[StrippedStateEvent]: + """ + The minimum amount of parsing necessary to ensure + `invite_room_state`/`knock_room_state` is at-least a list of stripped state events. + """ + + # Scrutinize JSON values + if not isinstance(stripped_room_state, list): + raise TypeError("Stripped state must be a list of PDU's") + + parsed_stripped_room_state = [] + for raw_stripped_event in stripped_room_state: + # Parse and serialize to strip the events down to only the necessary fields + parsed_stripped_event = parse_stripped_state_event(raw_stripped_event) + if parsed_stripped_event is None: + raise ValueError("Unable to parse as stripped event") + parsed_stripped_room_state.append(parsed_stripped_event) + + return parsed_stripped_room_state + async def _parse_stripped_room_state( self, - stripped_room_state_type: StrippedRoomStateType, - event: EventBase, + *, + stripped_room_state: Any, + room_id: str, room_version: RoomVersion, ) -> list[StrippedStateEvent]: """ @@ -1096,18 +1147,21 @@ async def _parse_stripped_room_state( the events contained within can be full PDU's or stripped state events (older version of the Matrix spec). + Args: + stripped_room_state: The raw `invite_room_state`/`knock_room_state` JSON + room_id: The room ID the invite/knock is happening in + room_version: The version of the room the invite/knock is happening in + Returns: A list of parsed `StrippedStateEvent` Raises: `TypeError`/`ValueError` when the stripped room state is invalid """ - stripped_room_state = event.unsigned.get(stripped_room_state_type.value) - # Scrutinize JSON values if not isinstance(stripped_room_state, list): raise TypeError( - f"`{stripped_room_state_type.value}` must be a list of PDU's that includes the `m.room.create` event" + "Stripped state must be a list of PDU's that includes the `m.room.create` event" ) parsed_stripped_room_state = [] @@ -1118,13 +1172,13 @@ async def _parse_stripped_room_state( pdu = event_from_pdu_json(raw_stripped_event, room_version) except Exception as exc: raise ValueError( - f"Unable to parse one of the `{stripped_room_state_type.value}` event's as a PDU" + "Unable to parse one of the stripped state events as a PDU" ) from exc # Validate that it's from the same room - if pdu.room_id != event.room_id: + if pdu.room_id != room_id: raise ValueError( - f"PDU from {stripped_room_state_type.value} must be from the room ID specified in the `/invite` request" + "PDU from stripped state must be from the room ID specified in the request" ) # Validate signature/hashes try: @@ -1133,7 +1187,7 @@ async def _parse_stripped_room_state( ) except InvalidEventSignatureError as exc: raise ValueError( - f"PDU from {stripped_room_state_type.value} must pass signature/hash checks" + "PDU from stripped state must pass signature/hash checks" ) from exc # Mark down whether we saw the create event which we will validate just below @@ -1152,7 +1206,7 @@ async def _parse_stripped_room_state( # Validate `m.room.create` event is included if not includes_create_event: raise ValueError( - f"`{stripped_room_state_type.value}` must include `m.room.create` event" + "Stripped state must include `m.room.create` event (MSC4311)" ) return parsed_stripped_room_state @@ -1238,7 +1292,9 @@ async def on_invite_request( # Parse/validate `invite_room_state` try: stripped_room_state = await self._parse_stripped_room_state( - StrippedRoomStateType.INVITE, event, room_version + stripped_room_state=event.unsigned.get("invite_room_state"), + room_id=event.room_id, + room_version=room_version, ) # Replace with our sanitized `invite_room_state` event.unsigned["invite_room_state"] = [ @@ -1260,10 +1316,17 @@ async def on_invite_request( exc, ) - # We did our best to sanitize `event.unsigned["invite_room_state"]` but - # ultimately failed. Leave it as-is for the client to interpret. Another - # valid decision would be to strip it from `unsigned` but this is more - # forwards compatible. + # FIXME(MSC4311): Remove this whole block after we always enforce the + # validation above. The only reason this is here is because the validation + # can fail for non-compliant servers but we should still use stripped state. + stripped_room_state = self._minimal_parse_stripped_room_state( + stripped_room_state=event.unsigned.get("invite_room_state"), + ) + # Replace with our sanitized `invite_room_state` + event.unsigned["invite_room_state"] = [ + serialize_stripped_state_event(stripped_state_event) + for stripped_state_event in stripped_room_state + ] event.internal_metadata.outlier = True event.internal_metadata.out_of_band_membership = True From 75a53ef4cf852c34a423188626e9c47bbde9df76 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 22 May 2026 20:38:18 -0500 Subject: [PATCH 23/23] Strip the knock itself we add down `/sync` --- synapse/rest/client/sync.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/synapse/rest/client/sync.py b/synapse/rest/client/sync.py index 5b73d616779..33667811425 100644 --- a/synapse/rest/client/sync.py +++ b/synapse/rest/client/sync.py @@ -514,8 +514,9 @@ async def encode_knocked( # the client with: # # * A knock state event that they can use for easier internal tracking - # * The rough timestamp of when the knock occurred contained within the event - knocked_state.append(knock) + # + # FIXME: Doesn't seem to be in the spec + knocked_state.append(strip_event(room.knock)) # Build the `knock_state` dictionary, which will contain the state of the # room that the client has knocked on