From bdfeb90e6e01718e4b4e7a7ef3313e55360f75d6 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 23 Apr 2026 12:18:04 -0500 Subject: [PATCH 01/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] `_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/48] `_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/48] 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 From 1926534029d5a024943ce5373262de92a5b7c354 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 26 May 2026 15:09:07 -0500 Subject: [PATCH 24/48] Account for previous Matrix versions where `invite_room_state` is optional --- synapse/handlers/federation.py | 68 ++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 4fed3b3277c..2b61decbf95 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -919,6 +919,13 @@ async def do_knock( 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). + # + # The Matrix spec says that for "version 12+ rooms, servers SHOULD rather than + # MAY respond to such requests with 400 M_MISSING_PARAM". Given we have the + # lee-way to enforce this in all room versions, we might as well. + # + # For now, we'll only log in room versions 12+ where this SHOULD be the case + # already. 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 @@ -934,14 +941,15 @@ async def do_knock( # 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_for_client = 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 - ] + if stripped_room_state_for_client is not None: + # 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_for_client + ] context = EventContext.for_outlier(self._storage_controllers) stream_id = await self._federation_event_handler.persist_events_and_notify( @@ -1106,22 +1114,33 @@ def _minimal_parse_stripped_room_state( self, *, stripped_room_state: Any, - ) -> list[StrippedStateEvent]: + ) -> list[StrippedStateEvent] | None: """ - The minimum amount of parsing necessary to ensure - `invite_room_state`/`knock_room_state` is at-least a list of stripped state events. + The goal of this function is to sanitize whatever we got from federation and + make it presentable to the client. The minimum amount of parsing necessary to + ensure `invite_room_state`/`knock_room_state` is at-least a list of stripped + state events (compared to `_parse_stripped_room_state`). """ # Scrutinize JSON values - if not isinstance(stripped_room_state, list): + # + # In previous versions of the Matrix spec, + # `invite_room_state`/`knock_room_state` was an optional list of stripped state + # events which means we can't strictly enforce that this is always present. + if stripped_room_state is None: + return None + # We're going to strictly enforce that they at-least gave us a list. + elif 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 + # Parse each stripped event parsed_stripped_event = parse_stripped_state_event(raw_stripped_event) if parsed_stripped_event is None: - raise ValueError("Unable to parse as stripped event") + # Drop any invalid stripped state events as this is spec'ed and we + # might as well save the client from dealing with anything crazy. + continue parsed_stripped_room_state.append(parsed_stripped_event) return parsed_stripped_room_state @@ -1302,8 +1321,16 @@ async def on_invite_request( 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). + # 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). + # + # The Matrix spec says that for "version 12+ rooms, servers SHOULD rather than + # MAY respond to such requests with 400 M_MISSING_PARAM". Given we have the + # lee-way to enforce this in all room versions, we might as well. + # + # For now, we'll only log in room versions 12+ where this SHOULD be the case + # already. 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 @@ -1319,14 +1346,15 @@ async def on_invite_request( # 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_for_client = 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 - ] + if stripped_room_state_for_client is not None: + # 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_for_client + ] event.internal_metadata.outlier = True event.internal_metadata.out_of_band_membership = True From 17c376348dc9c29b2c75d5913ed5225100a5d408 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 26 May 2026 15:17:36 -0500 Subject: [PATCH 25/48] Better `msc4311_stripped_state` description --- rust/src/room_versions.rs | 20 +++++++++----------- synapse/synapse_rust/room_versions.pyi | 20 +++++++++----------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/rust/src/room_versions.rs b/rust/src/room_versions.rs index 2a0e898f9c2..94252ecca99 100644 --- a/rust/src/room_versions.rs +++ b/rust/src/room_versions.rs @@ -161,24 +161,22 @@ 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 - /// `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. + /// Determines whether a room version *SHOULD* rather than *MAY* reject invites/knocks + /// with invalid stripped state events. /// /// According to MSC4311: - /// > If any of the events are not a PDU, not for the room ID specified, or fail + /// > If any of the [stripped state] 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. + /// Regardless of room version (we should always do these things): + /// 1. The `m.room.create` event *MUST* be included in + /// `invite_room_state`/`knock_room_state` when sending invites/knocks over the + /// federation API's. + /// 2. Use full PDU's in the `invite_room_state`/`knock_room_state` in the federation + /// API. The client API still uses stripped state. msc4311_stripped_state: bool, } diff --git a/synapse/synapse_rust/room_versions.pyi b/synapse/synapse_rust/room_versions.pyi index 0bd56ebda9a..c69fd77a224 100644 --- a/synapse/synapse_rust/room_versions.pyi +++ b/synapse/synapse_rust/room_versions.pyi @@ -125,24 +125,22 @@ 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 - `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. + Determines whether a room version *SHOULD* rather than *MAY* reject invites/knocks + with invalid stripped state events. According to MSC4311: - > If any of the events are not a PDU, not for the room ID specified, or fail + > If any of the [stripped state] 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. + Regardless of room version (we should always do these things): + 1. The `m.room.create` event *MUST* be included in + `invite_room_state`/`knock_room_state` when sending invites/knocks over the + federation API's. + 2. Use full PDU's in the `invite_room_state`/`knock_room_state` in the federation + API. The client API still uses stripped state. """ class RoomVersions: From 75d19350d6e33f345bf7ee09be76a72cebe95490 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 26 May 2026 15:56:13 -0500 Subject: [PATCH 26/48] The PDU's over federation can be full PDU's now --- tests/federation/transport/test_knocking.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/federation/transport/test_knocking.py b/tests/federation/transport/test_knocking.py index ec705676cce..cdaae5cfd75 100644 --- a/tests/federation/transport/test_knocking.py +++ b/tests/federation/transport/test_knocking.py @@ -184,14 +184,11 @@ def check_knock_room_state_against_room_state( expected_room_state[event_type]["content"], event["content"] ) - # Check the state key is correct + # Check the state_key is correct self.assertEqual( expected_room_state[event_type]["state_key"], event["state_key"] ) - # Ensure the event has been stripped - self.assertNotIn("signatures", event) - # Pop once we've found and processed a state event expected_room_state.pop(event_type) From 3888387fd633f8ff801d3752211d607cdb7ae8b8 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 26 May 2026 16:20:13 -0500 Subject: [PATCH 27/48] Raise 500 when federation returns 400 `M_MISSING_PARAM` --- synapse/federation/federation_client.py | 48 +++++++++++++++++++++---- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index 0bd0b17cf85..11c765fb900 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -24,6 +24,7 @@ import copy import itertools import logging +from http import HTTPStatus from typing import ( TYPE_CHECKING, AbstractSet, @@ -1395,11 +1396,6 @@ 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. @@ -1411,6 +1407,26 @@ async def _do_send_invite( "User's homeserver does not support this room version", Codes.UNSUPPORTED_ROOM_VERSION, ) + # 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. + elif ( + err.code == HTTPStatus.BAD_REQUEST + and err.errcode == Codes.MISSING_PARAM + ): + raise SynapseError( + 500, + f"Received {HTTPStatus.BAD_REQUEST} {Codes.MISSING_PARAM} response from remote homeserver " + "while trying to send the invite over federation. This indicates a compatability problem " + "between your homeserver and the homeserver you're trying to send the invite to " + "(either one could be at fault).", + Codes.UNKNOWN, + additional_fields={ + "cause": err.msg, + "destination_server": destination, + }, + ) else: raise err @@ -1425,11 +1441,29 @@ async def _do_send_invite( 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 + # 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 + err = e.to_synapse_error() + if ( + err.code == HTTPStatus.BAD_REQUEST + and err.errcode == Codes.MISSING_PARAM + ): + raise SynapseError( + 500, + f"Received {HTTPStatus.BAD_REQUEST} {Codes.MISSING_PARAM} response from remote homeserver " + "while trying to send the invite over federation. This indicates a compatability problem " + "between your homeserver and the homeserver you're trying to send the invite to " + "(either one could be at fault).", + Codes.UNKNOWN, + additional_fields={ + "cause": err.msg, + "destination_server": destination, + }, + ) + else: + raise err return content async def send_leave(self, destinations: Iterable[str], pdu: EventBase) -> None: From 259f151bedec1ddf7844d6a964cf4996ed6522f2 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 26 May 2026 17:40:38 -0500 Subject: [PATCH 28/48] Add note about future Complement tests --- synapse/handlers/federation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 2b61decbf95..f4da670619c 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -930,7 +930,9 @@ async def do_knock( # 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. + # now. Don't forget to unskip the + # `TestMSC4311RejectInvalidStrippedStateFederation` Complement tests as + # well. logger.warning( "Continuing anyway but failed to validate `knock_room_state` on knock %s (room_version=%s): %s", event, From c289e93b8206885c647bd3a1986743b829bffb1d Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Wed, 27 May 2026 15:35:34 -0500 Subject: [PATCH 29/48] Kick CI after Sytest fixes See https://github.com/matrix-org/sytest/pull/1425 From bf9ab2f2e061a690efeb29c4829004909aa74b21 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Jul 2026 17:41:17 -0500 Subject: [PATCH 30/48] Refactor to include `inviter_user_id` as part of stripped state See: - https://github.com/element-hq/synapse/pull/19723#discussion_r3318830203 - https://github.com/matrix-org/sytest/pull/1425#discussion_r3319446010 --- synapse/federation/federation_client.py | 4 +- synapse/federation/federation_server.py | 8 +-- synapse/handlers/message.py | 12 +++-- .../storage/databases/main/events_worker.py | 51 ++++++++++++------- 4 files changed, 47 insertions(+), 28 deletions(-) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index 3ccaf6614da..57bee8a9d45 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -144,7 +144,6 @@ 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) @@ -1367,7 +1366,8 @@ async def _do_send_invite( # # Find the full events based on the state at the time of the invite state_ids = await self.store.get_stripped_room_state_ids_from_event_context( - context, self._room_prejoin_state_types + context, + self.store.calculate_stripped_state_filter(inviter_user_id=pdu.sender), ) 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 9cb5b9f8e54..dfd7c19983f 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -194,8 +194,6 @@ def __init__(self, hs: "HomeServer"): hs.config.federation.federation_metrics_domains ) - self._room_prejoin_state_types = hs.config.api.room_prejoin_state - # Whether we have started handling old events in the staging area. self._started_handling_of_staged_events = False @@ -1031,7 +1029,11 @@ async def on_send_knock_request( # # 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 + context, + self.store.calculate_stripped_state_filter( + # None as this is a knock, not an invite + inviter_user_id=None + ), ) state_events = await self.store.get_events(state_ids) assert set(state_ids) == set(state_events.keys()), ( diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py index 491e6d87893..fe58462e17b 100644 --- a/synapse/handlers/message.py +++ b/synapse/handlers/message.py @@ -509,8 +509,6 @@ def __init__(self, hs: "HomeServer"): self._worker_lock_handler = hs.get_worker_locks_handler() self._policy_handler = hs.get_room_policy_handler() - self.room_prejoin_state_types = self.hs.config.api.room_prejoin_state - self.send_events = ReplicationSendEventsRestServlet.make_client(hs) self.request_ratelimiter = hs.get_request_ratelimiter() @@ -2079,8 +2077,9 @@ async def persist_and_notify_client_events( "invite_room_state", await self.store.get_stripped_room_state_from_event_context( context, - self.room_prejoin_state_types, - membership_user_id=event.sender, + self.store.calculate_stripped_state_filter( + inviter_user_id=event.sender + ), ), ) @@ -2104,7 +2103,10 @@ async def persist_and_notify_client_events( "knock_room_state", await self.store.get_stripped_room_state_from_event_context( context, - self.room_prejoin_state_types, + self.store.calculate_stripped_state_filter( + # None as this is a knock, not an invite + inviter_user_id=None + ), ), ) diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py index 9d1ef617d09..2cc4538b59a 100644 --- a/synapse/storage/databases/main/events_worker.py +++ b/synapse/storage/databases/main/events_worker.py @@ -385,6 +385,8 @@ def get_chain_id_txn(txn: Cursor) -> int: finished (so we don't have to keep querying it every time) """ + self._room_prejoin_state_types = hs.config.api.room_prejoin_state + def get_un_partial_stated_events_token(self, instance_name: str) -> int: return ( self._un_partial_stated_events_stream_id_gen.get_current_token_for_writer( @@ -1125,16 +1127,42 @@ async def _get_events_from_local_cache( return event_map + def calculate_stripped_state_filter( + self, + *, + inviter_user_id: str | None = None, + ) -> StateFilter: + """ + Calculate the stripped state filter necessary for an event + + Args: + inviter_user_id: An optional user ID to include the stripped membership state + events of. This is useful when generating the stripped state of a room for + invites. We want to send membership events of the inviter, so that the + invitee can display the inviter's profile information if the room lacks any. + """ + state_filter = self._room_prejoin_state_types + + # Include the state for `inviter_user_id` if specified + # + # FIXME: Doesn't seem to be in the spec + if inviter_user_id: + types = chain( + self._room_prejoin_state_types.to_types(), + [(EventTypes.Member, inviter_user_id)], + ) + state_filter = StateFilter.from_types(types) + + return state_filter + async def get_stripped_room_state_from_event_context( self, context: EventContext, state_keys_to_include: StateFilter, - membership_user_id: str | None = None, ) -> list[JsonDict]: """ Retrieve the stripped state from a room, 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. + from as well as the state types to include. "Stripped" state means that only the `type`, `state_key`, `content` and `sender` keys are included from each state event. @@ -1142,25 +1170,13 @@ async def get_stripped_room_state_from_event_context( 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. - membership_user_id: An optional user ID to include the stripped membership state - events of. This is useful when generating the stripped state of a room for - invites. We want to send membership events of the inviter, so that the - invitee can display the inviter's profile information if the room lacks any. Returns: A list of dictionaries, each representing a stripped state event from the room. """ - if membership_user_id: - types = chain( - state_keys_to_include.to_types(), - [(EventTypes.Member, membership_user_id)], - ) - filter = StateFilter.from_types(types) - else: - filter = state_keys_to_include selected_state_ids = await self.get_stripped_room_state_ids_from_event_context( - context, filter + context, state_keys_to_include ) state_to_include = await self.get_events(selected_state_ids) @@ -1174,8 +1190,7 @@ async def get_stripped_room_state_ids_from_event_context( ) -> 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. + from as well as the state types to include. "Stripped" state means that only the `type`, `state_key`, `content` and `sender` keys are included from each state event. From c54c93b481c6742d5d975bfdf678362098720bf3 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Jul 2026 17:48:20 -0500 Subject: [PATCH 31/48] Just pass in the event --- synapse/federation/federation_client.py | 2 +- synapse/federation/federation_server.py | 8 ++------ synapse/handlers/message.py | 9 ++------- .../storage/databases/main/events_worker.py | 20 ++++++++++++------- 4 files changed, 18 insertions(+), 21 deletions(-) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index 57bee8a9d45..d5e9b75a2ed 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -1366,8 +1366,8 @@ async def _do_send_invite( # # Find the full events based on the state at the time of the invite state_ids = await self.store.get_stripped_room_state_ids_from_event_context( + pdu, context, - self.store.calculate_stripped_state_filter(inviter_user_id=pdu.sender), ) 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 dfd7c19983f..12931ef0574 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -1020,7 +1020,7 @@ async def on_send_knock_request( """ time_now = self._clock.time_msec() - _, context = await self._on_send_membership_event( + event, context = await self._on_send_membership_event( origin, content, Membership.KNOCK, room_id ) @@ -1029,11 +1029,7 @@ async def on_send_knock_request( # # 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.store.calculate_stripped_state_filter( - # None as this is a knock, not an invite - inviter_user_id=None - ), + event, context ) state_events = await self.store.get_events(state_ids) assert set(state_ids) == set(state_events.keys()), ( diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py index fe58462e17b..e631b738d97 100644 --- a/synapse/handlers/message.py +++ b/synapse/handlers/message.py @@ -2076,10 +2076,8 @@ async def persist_and_notify_client_events( event.unsigned, "invite_room_state", await self.store.get_stripped_room_state_from_event_context( + event, context, - self.store.calculate_stripped_state_filter( - inviter_user_id=event.sender - ), ), ) @@ -2102,11 +2100,8 @@ async def persist_and_notify_client_events( event.unsigned, "knock_room_state", await self.store.get_stripped_room_state_from_event_context( + event, context, - self.store.calculate_stripped_state_filter( - # None as this is a knock, not an invite - inviter_user_id=None - ), ), ) diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py index 2cc4538b59a..a36289c81ed 100644 --- a/synapse/storage/databases/main/events_worker.py +++ b/synapse/storage/databases/main/events_worker.py @@ -41,7 +41,7 @@ from twisted.internet import defer -from synapse.api.constants import Direction, EventTypes +from synapse.api.constants import Direction, EventTypes, Membership from synapse.api.errors import NotFoundError, SynapseError from synapse.api.room_versions import ( KNOWN_ROOM_VERSIONS, @@ -1127,7 +1127,7 @@ async def _get_events_from_local_cache( return event_map - def calculate_stripped_state_filter( + def _calculate_stripped_state_filter( self, *, inviter_user_id: str | None = None, @@ -1157,8 +1157,8 @@ def calculate_stripped_state_filter( async def get_stripped_room_state_from_event_context( self, + event: EventBase, context: EventContext, - state_keys_to_include: StateFilter, ) -> list[JsonDict]: """ Retrieve the stripped state from a room, given an event context to retrieve state @@ -1174,9 +1174,8 @@ async def get_stripped_room_state_from_event_context( Returns: A list of dictionaries, each representing a stripped state event from the room. """ - selected_state_ids = await self.get_stripped_room_state_ids_from_event_context( - context, state_keys_to_include + event, context ) state_to_include = await self.get_events(selected_state_ids) @@ -1185,8 +1184,8 @@ async def get_stripped_room_state_from_event_context( async def get_stripped_room_state_ids_from_event_context( self, + event: EventBase, context: EventContext, - state_keys_to_include: StateFilter, ) -> list[str]: """ Retrieve the stripped state IDs for an event, given an event context to retrieve state @@ -1197,11 +1196,18 @@ async def get_stripped_room_state_ids_from_event_context( 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 """ + is_invite_event = ( + event.type == EventTypes.Member and event.membership == Membership.INVITE + ) + + # Get the relevant state + state_keys_to_include = self._calculate_stripped_state_filter( + inviter_user_id=event.sender if is_invite_event else None + ) 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 From 17a4ef6fa972d660a0a5358b188331f6d4ab8b22 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Jul 2026 17:58:41 -0500 Subject: [PATCH 32/48] Remove `_calculate_stripped_state_filter` --- .../storage/databases/main/events_worker.py | 48 ++++++------------- 1 file changed, 15 insertions(+), 33 deletions(-) diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py index a36289c81ed..0b443c911fe 100644 --- a/synapse/storage/databases/main/events_worker.py +++ b/synapse/storage/databases/main/events_worker.py @@ -1127,34 +1127,6 @@ async def _get_events_from_local_cache( return event_map - def _calculate_stripped_state_filter( - self, - *, - inviter_user_id: str | None = None, - ) -> StateFilter: - """ - Calculate the stripped state filter necessary for an event - - Args: - inviter_user_id: An optional user ID to include the stripped membership state - events of. This is useful when generating the stripped state of a room for - invites. We want to send membership events of the inviter, so that the - invitee can display the inviter's profile information if the room lacks any. - """ - state_filter = self._room_prejoin_state_types - - # Include the state for `inviter_user_id` if specified - # - # FIXME: Doesn't seem to be in the spec - if inviter_user_id: - types = chain( - self._room_prejoin_state_types.to_types(), - [(EventTypes.Member, inviter_user_id)], - ) - state_filter = StateFilter.from_types(types) - - return state_filter - async def get_stripped_room_state_from_event_context( self, event: EventBase, @@ -1200,15 +1172,25 @@ async def get_stripped_room_state_ids_from_event_context( Returns: A list of event_ids, each representing the stripped state event to include for this event """ + # Start with the configured default set of stripped state to include + state_filter = self._room_prejoin_state_types + + # We want to send membership events of the inviter, so that the invitee can + # display the inviter's profile information if the room lacks any. + # + # FIXME: Doesn't seem to be in the spec is_invite_event = ( event.type == EventTypes.Member and event.membership == Membership.INVITE ) + if is_invite_event: + types = chain( + self._room_prejoin_state_types.to_types(), + [(EventTypes.Member, event.sender)], + ) + state_filter = StateFilter.from_types(types) # Get the relevant state - state_keys_to_include = self._calculate_stripped_state_filter( - inviter_user_id=event.sender if is_invite_event else None - ) - selected_state_ids = await context.get_current_state_ids(state_keys_to_include) + selected_state_ids = await context.get_current_state_ids(state_filter) # We know this event is not an outlier, so this must be # non-None. @@ -1216,7 +1198,7 @@ async def get_stripped_room_state_ids_from_event_context( # 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) + selected_state_ids = state_filter.filter_state(selected_state_ids) return list(selected_state_ids.values()) From 1cf8282551aaac0cde9f11c79323344f9ccf93ed Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Jul 2026 17:59:24 -0500 Subject: [PATCH 33/48] Update date that Synapse is compliant --- 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 f4da670619c..8dcffbaa284 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -929,7 +929,7 @@ async def do_knock( 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 + # didn't adhere to this behavior until 2026-07, we will only warn for # now. Don't forget to unskip the # `TestMSC4311RejectInvalidStrippedStateFederation` Complement tests as # well. @@ -1336,7 +1336,7 @@ async def on_invite_request( 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 + # didn't adhere to this behavior until 2026-07, we will only warn for # now. logger.warning( "Continuing anyway but failed to validate `invite_room_state` on invite %s (room_version=%s): %s", From ce17c93fa5cc3cb9030053ad1e74e27d078fde9a Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Jul 2026 18:00:17 -0500 Subject: [PATCH 34/48] Cross-link #19943 See https://github.com/element-hq/synapse/issues/19943 --- synapse/handlers/federation.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 8dcffbaa284..ab9775db40c 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -917,8 +917,9 @@ async def do_knock( 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). + # 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), see https://github.com/element-hq/synapse/issues/19943 # # The Matrix spec says that for "version 12+ rooms, servers SHOULD rather than # MAY respond to such requests with 400 M_MISSING_PARAM". Given we have the @@ -1325,7 +1326,7 @@ async def on_invite_request( 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). + # MSC4311), see https://github.com/element-hq/synapse/issues/19943 # # The Matrix spec says that for "version 12+ rooms, servers SHOULD rather than # MAY respond to such requests with 400 M_MISSING_PARAM". Given we have the From 5cc8753bd342ef84f0bcd0b98c5696482e44c6f8 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Jul 2026 18:01:41 -0500 Subject: [PATCH 35/48] Fix `compatibility` typo --- 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 d5e9b75a2ed..3813f5ec155 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -1412,7 +1412,7 @@ async def _do_send_invite( raise SynapseError( 500, f"Received {HTTPStatus.BAD_REQUEST} {Codes.MISSING_PARAM} response from remote homeserver " - "while trying to send the invite over federation. This indicates a compatability problem " + "while trying to send the invite over federation. This indicates a compatibility problem " "between your homeserver and the homeserver you're trying to send the invite to " "(either one could be at fault).", Codes.UNKNOWN, @@ -1447,7 +1447,7 @@ async def _do_send_invite( raise SynapseError( 500, f"Received {HTTPStatus.BAD_REQUEST} {Codes.MISSING_PARAM} response from remote homeserver " - "while trying to send the invite over federation. This indicates a compatability problem " + "while trying to send the invite over federation. This indicates a compatibility problem " "between your homeserver and the homeserver you're trying to send the invite to " "(either one could be at fault).", Codes.UNKNOWN, From 53a68d7301030022cf1a4e404c6178264f94c97d Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Jul 2026 18:06:25 -0500 Subject: [PATCH 36/48] Use correct `knock_room_state` reference from response See https://github.com/element-hq/synapse/pull/19723#discussion_r3318830110 --- synapse/handlers/federation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index ab9775db40c..3919a42b5b1 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -945,7 +945,7 @@ async def do_knock( # 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_for_client = self._minimal_parse_stripped_room_state( - stripped_room_state=event.unsigned.get("knock_room_state"), + stripped_room_state=knock_response.get("knock_room_state"), ) if stripped_room_state_for_client is not None: # Replace with our sanitized `knock_room_state` From 7488a244b9a7e5929acd433a07332fe47acbdda0 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Jul 2026 18:58:34 -0500 Subject: [PATCH 37/48] Use full PDU's for v1 of federation invite API See https://github.com/element-hq/synapse/pull/19723#discussion_r3318830048 --- synapse/federation/federation_client.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index 3813f5ec155..76e3a50083f 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -1428,11 +1428,24 @@ async def _do_send_invite( # Note the v1 API returns a tuple of `(200, content)` try: + # Use full PDU's for `invite_room_state` according to MSC4311 + # + # With the v1 invite API, `invite_room_state` is carried inside the event + # instead of a separate field like in v2 so we must munge it in ourselves + event_json = pdu.get_pdu_json(time_now) + event_json.setdefault("unsigned", {})["invite_room_state"] = ( + [ + # Use full PDU's according to MSC4311 + state_event.get_pdu_json(time_now) + for state_event in state_events.values() + ], + ) + _, 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), + content=event_json, ) except HttpResponseException as e: # MSC4311: The 400 `M_MISSING_PARAM` error SHOULD be translated to a 5xx From 387003448cbecfd559656992be470fbf31b8c319 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Jul 2026 19:29:41 -0500 Subject: [PATCH 38/48] Fix typo causing extra nesting --- synapse/federation/federation_client.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index 76e3a50083f..0e27f29e8a4 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -1433,13 +1433,11 @@ async def _do_send_invite( # With the v1 invite API, `invite_room_state` is carried inside the event # instead of a separate field like in v2 so we must munge it in ourselves event_json = pdu.get_pdu_json(time_now) - event_json.setdefault("unsigned", {})["invite_room_state"] = ( - [ - # Use full PDU's according to MSC4311 - state_event.get_pdu_json(time_now) - for state_event in state_events.values() - ], - ) + event_json.setdefault("unsigned", {})["invite_room_state"] = [ + # Use full PDU's according to MSC4311 + state_event.get_pdu_json(time_now) + for state_event in state_events.values() + ] _, content = await self.transport_layer.send_invite_v1( destination=destination, From 40d1316143d79b0458f6f22130ba1842951c5232 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Jul 2026 21:01:51 -0500 Subject: [PATCH 39/48] Kick CI after deleting sytest branch with same name From 3fc94ba34a9acff56e6dc02c4ea13a9ce3f20a34 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 9 Jul 2026 21:22:05 -0500 Subject: [PATCH 40/48] Fix arg ordering typo with `SynapseError` --- synapse/federation/federation_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/synapse/federation/federation_server.py b/synapse/federation/federation_server.py index 12931ef0574..aeb536908be 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -800,14 +800,14 @@ async def on_invite_request( 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", + Codes.INVALID_PARAM, ) 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", + Codes.INVALID_PARAM, ) if await self._spam_checker_module_callbacks.should_drop_federated_event(pdu): logger.info( From 26bf7390ebfbc1875235e3ac07a2d348638e7d31 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Wed, 22 Jul 2026 14:14:35 -0500 Subject: [PATCH 41/48] Cross-link MSC4319 --- synapse/rest/client/sync.py | 6 ++++-- synapse/storage/databases/main/events_worker.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/synapse/rest/client/sync.py b/synapse/rest/client/sync.py index 04b8f27ab87..b49156c58d6 100644 --- a/synapse/rest/client/sync.py +++ b/synapse/rest/client/sync.py @@ -462,7 +462,8 @@ async def encode_invited( invited_state = list(invited_state) # Add the invite itself # - # FIXME: Doesn't seem to be in the spec + # FIXME: Doesn't seem to be in the spec but tracked by + # [MSC4319](https://github.com/matrix-org/matrix-spec-proposals/pull/4319) invited_state.append(strip_event(room.invite)) invited[room.room_id] = {"invite_state": {"events": invited_state}} @@ -514,7 +515,8 @@ async def encode_knocked( # # * A knock state event that they can use for easier internal tracking # - # FIXME: Doesn't seem to be in the spec + # FIXME: Doesn't seem to be in the spec but tracked by + # [MSC4319](https://github.com/matrix-org/matrix-spec-proposals/pull/4319) knocked_state.append(strip_event(room.knock)) # Build the `knock_state` dictionary, which will contain the state of the diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py index 0b443c911fe..ccfcefb6ee6 100644 --- a/synapse/storage/databases/main/events_worker.py +++ b/synapse/storage/databases/main/events_worker.py @@ -1178,7 +1178,8 @@ async def get_stripped_room_state_ids_from_event_context( # We want to send membership events of the inviter, so that the invitee can # display the inviter's profile information if the room lacks any. # - # FIXME: Doesn't seem to be in the spec + # FIXME: Doesn't seem to be in the spec but tracked by + # [MSC4319](https://github.com/matrix-org/matrix-spec-proposals/pull/4319) is_invite_event = ( event.type == EventTypes.Member and event.membership == Membership.INVITE ) From 2822f3ccefdd458422983768fe11227089418571 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 28 Jul 2026 16:07:24 -0500 Subject: [PATCH 42/48] Move parse/serialize as methods of `StrippedStateEvent` See https://github.com/element-hq/synapse/pull/19723#discussion_r3630648945 --- synapse/events/__init__.py | 40 +++++++++++++++++++++ synapse/events/utils.py | 38 +------------------- synapse/handlers/federation.py | 16 ++++----- synapse/handlers/sliding_sync/room_lists.py | 3 +- synapse/storage/databases/main/events.py | 5 +-- 5 files changed, 49 insertions(+), 53 deletions(-) diff --git a/synapse/events/__init__.py b/synapse/events/__init__.py index b01e786550a..6859d849952 100644 --- a/synapse/events/__init__.py +++ b/synapse/events/__init__.py @@ -199,6 +199,46 @@ class StrippedStateEvent: sender: str content: dict[str, Any] + def serialize(self) -> JsonDict: + """ + Serialize to the JSON representation of 'Stripped State' according to the Matrix + spec + """ + + return { + "type": self.type, + "state_key": self.state_key, + "sender": self.sender, + "content": self.content, + } + + @staticmethod + def parse(raw_stripped_event: Any) -> "StrippedStateEvent" | None: + """ + Given a raw value from an event's `unsigned` field, attempt to parse it into a + `StrippedStateEvent`. + """ + if isinstance(raw_stripped_event, dict): + # All of these fields are required + type = raw_stripped_event.get("type") + state_key = raw_stripped_event.get("state_key") + sender = raw_stripped_event.get("sender") + content = raw_stripped_event.get("content") + if ( + isinstance(type, str) + and isinstance(state_key, str) + and isinstance(sender, str) + and isinstance(content, dict) + ): + return StrippedStateEvent( + type=type, + state_key=state_key, + sender=sender, + content=content, + ) + + return None + @attr.s(slots=True, frozen=True, auto_attribs=True) class EventMetadata: diff --git a/synapse/events/utils.py b/synapse/events/utils.py index 5db31dff369..c3bc6fa9da1 100644 --- a/synapse/events/utils.py +++ b/synapse/events/utils.py @@ -54,7 +54,7 @@ from synapse.synapse_rust.types import Requester from synapse.types import JsonDict -from . import EventBase, StrippedStateEvent +from . import EventBase # These are imported only to re-export them (callers import them from this # module); listing them in __all__ stops the unused-import lint flagging them @@ -554,39 +554,3 @@ def strip_event(event: EventBase) -> JsonDict: "content": dict(event.content), "sender": event.sender, } - - -def parse_stripped_state_event(raw_stripped_event: Any) -> StrippedStateEvent | None: - """ - Given a raw value from an event's `unsigned` field, attempt to parse it into a - `StrippedStateEvent`. - """ - if isinstance(raw_stripped_event, dict): - # All of these fields are required - type = raw_stripped_event.get("type") - state_key = raw_stripped_event.get("state_key") - sender = raw_stripped_event.get("sender") - content = raw_stripped_event.get("content") - if ( - isinstance(type, str) - and isinstance(state_key, str) - and isinstance(sender, str) - and isinstance(content, dict) - ): - return StrippedStateEvent( - type=type, - state_key=state_key, - sender=sender, - content=content, - ) - - 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/handlers/federation.py b/synapse/handlers/federation.py index 3919a42b5b1..be53d3c7974 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -60,10 +60,6 @@ from synapse.event_auth import validate_event_for_room_version from synapse.events import EventBase, StrippedStateEvent 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, @@ -913,7 +909,7 @@ async def do_knock( ) # Replace with our sanitized `knock_room_state` event.unsigned["knock_room_state"] = [ - serialize_stripped_state_event(stripped_state_event) + stripped_state_event.serialize() for stripped_state_event in stripped_room_state ] except Exception as exc: @@ -950,7 +946,7 @@ async def do_knock( if stripped_room_state_for_client is not None: # Replace with our sanitized `knock_room_state` event.unsigned["knock_room_state"] = [ - serialize_stripped_state_event(stripped_state_event) + stripped_state_event.serialize() for stripped_state_event in stripped_room_state_for_client ] @@ -1139,7 +1135,7 @@ def _minimal_parse_stripped_room_state( parsed_stripped_room_state = [] for raw_stripped_event in stripped_room_state: # Parse each stripped event - parsed_stripped_event = parse_stripped_state_event(raw_stripped_event) + parsed_stripped_event = StrippedStateEvent.parse(raw_stripped_event) if parsed_stripped_event is None: # Drop any invalid stripped state events as this is spec'ed and we # might as well save the client from dealing with anything crazy. @@ -1220,7 +1216,7 @@ async def _parse_stripped_room_state( 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) + parsed_stripped_event = StrippedStateEvent.parse(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) @@ -1320,7 +1316,7 @@ async def on_invite_request( ) # Replace with our sanitized `invite_room_state` event.unsigned["invite_room_state"] = [ - serialize_stripped_state_event(stripped_state_event) + stripped_state_event.serialize() for stripped_state_event in stripped_room_state ] except Exception as exc: @@ -1355,7 +1351,7 @@ async def on_invite_request( if stripped_room_state_for_client is not None: # Replace with our sanitized `invite_room_state` event.unsigned["invite_room_state"] = [ - serialize_stripped_state_event(stripped_state_event) + stripped_state_event.serialize() for stripped_state_event in stripped_room_state_for_client ] diff --git a/synapse/handlers/sliding_sync/room_lists.py b/synapse/handlers/sliding_sync/room_lists.py index 836cee6c20f..cca4e6d2840 100644 --- a/synapse/handlers/sliding_sync/room_lists.py +++ b/synapse/handlers/sliding_sync/room_lists.py @@ -37,7 +37,6 @@ from synapse.api.errors import SlidingSyncUnknownPosition from synapse.api.room_versions import KNOWN_ROOM_VERSIONS from synapse.events import StrippedStateEvent -from synapse.events.utils import parse_stripped_state_event from synapse.logging.opentracing import start_active_span, trace from synapse.storage.databases.main.sliding_sync import UPDATE_INTERVAL_LAST_USED_TS from synapse.storage.databases.main.state import ( @@ -1618,7 +1617,7 @@ async def _bulk_get_stripped_state_for_rooms_from_sync_room_map( stripped_state_map = {} if isinstance(raw_stripped_state_events, list): for raw_stripped_event in raw_stripped_state_events: - stripped_state_event = parse_stripped_state_event( + stripped_state_event = StrippedStateEvent.parse( raw_stripped_event ) if stripped_state_event is not None: diff --git a/synapse/storage/databases/main/events.py b/synapse/storage/databases/main/events.py index 84b38f4bf2a..8f50b6b0e6c 100644 --- a/synapse/storage/databases/main/events.py +++ b/synapse/storage/databases/main/events.py @@ -55,7 +55,6 @@ ) from synapse.events.py_protocol import MSC4242Event, supports_msc4242_state_dag from synapse.events.snapshot import EventPersistencePair -from synapse.events.utils import parse_stripped_state_event from synapse.logging.opentracing import trace from synapse.metrics import SERVER_NAME_LABEL from synapse.storage._base import db_to_json, make_in_list_sql_clause @@ -2252,9 +2251,7 @@ def _get_sliding_sync_insert_values_from_stripped_state( stripped_state_map: MutableStateMap[StrippedStateEvent] = {} if isinstance(unsigned_stripped_state_events, list): for raw_stripped_event in unsigned_stripped_state_events: - stripped_state_event = parse_stripped_state_event( - raw_stripped_event - ) + stripped_state_event = StrippedStateEvent.parse(raw_stripped_event) if stripped_state_event is not None: stripped_state_map[ ( From 3ce6900715b519bb1e04f232c43c0a34e50ea5ad Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 28 Jul 2026 16:23:24 -0500 Subject: [PATCH 43/48] Rename `parse` to `from_json_dict` --- synapse/events/__init__.py | 2 +- synapse/handlers/federation.py | 8 ++++++-- synapse/handlers/sliding_sync/room_lists.py | 2 +- synapse/storage/databases/main/events.py | 4 +++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/synapse/events/__init__.py b/synapse/events/__init__.py index 6859d849952..371f271dc2d 100644 --- a/synapse/events/__init__.py +++ b/synapse/events/__init__.py @@ -213,7 +213,7 @@ def serialize(self) -> JsonDict: } @staticmethod - def parse(raw_stripped_event: Any) -> "StrippedStateEvent" | None: + def from_json_dict(raw_stripped_event: JsonDict) -> "StrippedStateEvent" | None: """ Given a raw value from an event's `unsigned` field, attempt to parse it into a `StrippedStateEvent`. diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index be53d3c7974..8202be4e8b2 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -1135,7 +1135,9 @@ def _minimal_parse_stripped_room_state( parsed_stripped_room_state = [] for raw_stripped_event in stripped_room_state: # Parse each stripped event - parsed_stripped_event = StrippedStateEvent.parse(raw_stripped_event) + parsed_stripped_event = StrippedStateEvent.from_json_dict( + raw_stripped_event + ) if parsed_stripped_event is None: # Drop any invalid stripped state events as this is spec'ed and we # might as well save the client from dealing with anything crazy. @@ -1216,7 +1218,9 @@ async def _parse_stripped_room_state( includes_create_event = True # Parse the stripped events to ensure it has all of the fields necessary - parsed_stripped_event = StrippedStateEvent.parse(raw_stripped_event) + parsed_stripped_event = StrippedStateEvent.from_json_dict( + 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) diff --git a/synapse/handlers/sliding_sync/room_lists.py b/synapse/handlers/sliding_sync/room_lists.py index cca4e6d2840..f4d1f484e78 100644 --- a/synapse/handlers/sliding_sync/room_lists.py +++ b/synapse/handlers/sliding_sync/room_lists.py @@ -1617,7 +1617,7 @@ async def _bulk_get_stripped_state_for_rooms_from_sync_room_map( stripped_state_map = {} if isinstance(raw_stripped_state_events, list): for raw_stripped_event in raw_stripped_state_events: - stripped_state_event = StrippedStateEvent.parse( + stripped_state_event = StrippedStateEvent.from_json_dict( raw_stripped_event ) if stripped_state_event is not None: diff --git a/synapse/storage/databases/main/events.py b/synapse/storage/databases/main/events.py index 8f50b6b0e6c..e2900873526 100644 --- a/synapse/storage/databases/main/events.py +++ b/synapse/storage/databases/main/events.py @@ -2251,7 +2251,9 @@ def _get_sliding_sync_insert_values_from_stripped_state( stripped_state_map: MutableStateMap[StrippedStateEvent] = {} if isinstance(unsigned_stripped_state_events, list): for raw_stripped_event in unsigned_stripped_state_events: - stripped_state_event = StrippedStateEvent.parse(raw_stripped_event) + stripped_state_event = StrippedStateEvent.from_json_dict( + raw_stripped_event + ) if stripped_state_event is not None: stripped_state_map[ ( From 19d8a7a6e99ca2abfc27c54b17a39f5eb4de2a18 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 28 Jul 2026 16:26:53 -0500 Subject: [PATCH 44/48] Rename `serialize` to `as_json_dict` --- synapse/events/__init__.py | 2 +- synapse/handlers/federation.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/synapse/events/__init__.py b/synapse/events/__init__.py index 371f271dc2d..802bda2139c 100644 --- a/synapse/events/__init__.py +++ b/synapse/events/__init__.py @@ -199,7 +199,7 @@ class StrippedStateEvent: sender: str content: dict[str, Any] - def serialize(self) -> JsonDict: + def as_json_dict(self) -> JsonDict: """ Serialize to the JSON representation of 'Stripped State' according to the Matrix spec diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 8202be4e8b2..2bdca920b98 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -909,7 +909,7 @@ async def do_knock( ) # Replace with our sanitized `knock_room_state` event.unsigned["knock_room_state"] = [ - stripped_state_event.serialize() + stripped_state_event.as_json_dict() for stripped_state_event in stripped_room_state ] except Exception as exc: @@ -946,7 +946,7 @@ async def do_knock( if stripped_room_state_for_client is not None: # Replace with our sanitized `knock_room_state` event.unsigned["knock_room_state"] = [ - stripped_state_event.serialize() + stripped_state_event.as_json_dict() for stripped_state_event in stripped_room_state_for_client ] @@ -1320,7 +1320,7 @@ async def on_invite_request( ) # Replace with our sanitized `invite_room_state` event.unsigned["invite_room_state"] = [ - stripped_state_event.serialize() + stripped_state_event.as_json_dict() for stripped_state_event in stripped_room_state ] except Exception as exc: @@ -1355,7 +1355,7 @@ async def on_invite_request( if stripped_room_state_for_client is not None: # Replace with our sanitized `invite_room_state` event.unsigned["invite_room_state"] = [ - stripped_state_event.serialize() + stripped_state_event.as_json_dict() for stripped_state_event in stripped_room_state_for_client ] From ecab104977c50becbed099f6932ce35c1c0ea814 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 28 Jul 2026 18:11:26 -0500 Subject: [PATCH 45/48] Fix `builtins.TypeError: unsupported operand type(s) for |: 'str' and 'NoneType'` --- synapse/events/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/events/__init__.py b/synapse/events/__init__.py index 802bda2139c..cab2fe1a46b 100644 --- a/synapse/events/__init__.py +++ b/synapse/events/__init__.py @@ -213,7 +213,7 @@ def as_json_dict(self) -> JsonDict: } @staticmethod - def from_json_dict(raw_stripped_event: JsonDict) -> "StrippedStateEvent" | None: + def from_json_dict(raw_stripped_event: JsonDict) -> "StrippedStateEvent | None": """ Given a raw value from an event's `unsigned` field, attempt to parse it into a `StrippedStateEvent`. From eed3071aa39f5cdf285a78b8a1f2862e8e780133 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 28 Jul 2026 18:29:26 -0500 Subject: [PATCH 46/48] MSC4319 is now merged --- synapse/rest/client/sync.py | 12 +++--------- synapse/storage/databases/main/events_worker.py | 7 ++----- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/synapse/rest/client/sync.py b/synapse/rest/client/sync.py index b49156c58d6..9b8b4dd98dc 100644 --- a/synapse/rest/client/sync.py +++ b/synapse/rest/client/sync.py @@ -460,10 +460,7 @@ async def encode_invited( invited_state = [] invited_state = list(invited_state) - # Add the invite itself - # - # FIXME: Doesn't seem to be in the spec but tracked by - # [MSC4319](https://github.com/matrix-org/matrix-spec-proposals/pull/4319) + # MSC4319: Add the invite itself invited_state.append(strip_event(room.invite)) invited[room.room_id] = {"invite_state": {"events": invited_state}} @@ -510,13 +507,10 @@ async def encode_knocked( knocked_state = [] knocked_state = list(knocked_state) - # Append the actual knock membership event itself as well. This provides - # the client with: + # MSC4319: Append the actual knock membership event itself as well. This + # provides the client with: # # * A knock state event that they can use for easier internal tracking - # - # FIXME: Doesn't seem to be in the spec but tracked by - # [MSC4319](https://github.com/matrix-org/matrix-spec-proposals/pull/4319) knocked_state.append(strip_event(room.knock)) # Build the `knock_state` dictionary, which will contain the state of the diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py index ccfcefb6ee6..1a606eeae5a 100644 --- a/synapse/storage/databases/main/events_worker.py +++ b/synapse/storage/databases/main/events_worker.py @@ -1175,11 +1175,8 @@ async def get_stripped_room_state_ids_from_event_context( # Start with the configured default set of stripped state to include state_filter = self._room_prejoin_state_types - # We want to send membership events of the inviter, so that the invitee can - # display the inviter's profile information if the room lacks any. - # - # FIXME: Doesn't seem to be in the spec but tracked by - # [MSC4319](https://github.com/matrix-org/matrix-spec-proposals/pull/4319) + # MSC4319: We want to send membership events of the inviter, so that the invitee + # can display the inviter's profile information if the room lacks any. is_invite_event = ( event.type == EventTypes.Member and event.membership == Membership.INVITE ) From e301cdd45037b441064c407b1d9a7783d9634b67 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Wed, 29 Jul 2026 14:36:36 -0500 Subject: [PATCH 47/48] Start error off more plainly: `The other user's server did not understand our invite.` See https://github.com/element-hq/synapse/pull/19723#discussion_r3673518127 --- synapse/federation/federation_client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index 0e27f29e8a4..1d5ec1024d7 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -1411,6 +1411,7 @@ async def _do_send_invite( ): raise SynapseError( 500, + "The other user's server did not understand our invite. " f"Received {HTTPStatus.BAD_REQUEST} {Codes.MISSING_PARAM} response from remote homeserver " "while trying to send the invite over federation. This indicates a compatibility problem " "between your homeserver and the homeserver you're trying to send the invite to " @@ -1457,6 +1458,7 @@ async def _do_send_invite( ): raise SynapseError( 500, + "The other user's server did not understand our invite. " f"Received {HTTPStatus.BAD_REQUEST} {Codes.MISSING_PARAM} response from remote homeserver " "while trying to send the invite over federation. This indicates a compatibility problem " "between your homeserver and the homeserver you're trying to send the invite to " From 4f70688c7d102b7d1a36f8b2605f457bf72997c7 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 3 Aug 2026 15:57:50 -0500 Subject: [PATCH 48/48] Iterate on error See https://github.com/element-hq/synapse/pull/19723#discussion_r3707629361 --- synapse/federation/federation_client.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index 1d5ec1024d7..de0ae6cb240 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -1411,11 +1411,10 @@ async def _do_send_invite( ): raise SynapseError( 500, - "The other user's server did not understand our invite. " - f"Received {HTTPStatus.BAD_REQUEST} {Codes.MISSING_PARAM} response from remote homeserver " - "while trying to send the invite over federation. This indicates a compatibility problem " - "between your homeserver and the homeserver you're trying to send the invite to " - "(either one could be at fault).", + f"Invite was rejected by the recipient's server.\n\n" + f"The remote homeserver ({destination}) returned {HTTPStatus.BAD_REQUEST} {Codes.MISSING_PARAM} " + "which indicates a compatibility problem between your homeserver and the " + "homeserver you're trying to send the invite to (either one could be at fault).", Codes.UNKNOWN, additional_fields={ "cause": err.msg, @@ -1458,11 +1457,10 @@ async def _do_send_invite( ): raise SynapseError( 500, - "The other user's server did not understand our invite. " - f"Received {HTTPStatus.BAD_REQUEST} {Codes.MISSING_PARAM} response from remote homeserver " - "while trying to send the invite over federation. This indicates a compatibility problem " - "between your homeserver and the homeserver you're trying to send the invite to " - "(either one could be at fault).", + f"Invite was rejected by the recipient's server.\n\n" + f"The remote homeserver ({destination}) returned {HTTPStatus.BAD_REQUEST} {Codes.MISSING_PARAM} " + "which indicates a compatibility problem between your homeserver and the " + "homeserver you're trying to send the invite to (either one could be at fault).", Codes.UNKNOWN, additional_fields={ "cause": err.msg,