diff --git a/rust/src/room_versions.rs b/rust/src/room_versions.rs index dbc962174d..2dc7350194 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 153352d011..9f7dbe0c8e 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 e8347324d1..b0d8289051 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -1405,6 +1405,11 @@ class FederationClient(FederationBase): }, ) 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 @@ class FederationClient(FederationBase): 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 7018eea1ad..1097705def 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -772,8 +772,22 @@ class FederationServer(FederationBase): 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 @@ class FederationServer(FederationBase): 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 @@ class FederationServer(FederationBase): 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 d783e6da51..7c753ccfa3 100644 --- a/synapse/federation/transport/server/federation.py +++ b/synapse/federation/transport/server/federation.py @@ -490,7 +490,11 @@ class FederationV1InviteServlet(BaseFederationServerServlet): # 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 @@ class FederationV2InviteServlet(BaseFederationServerServlet): 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 @@ class FederationV2InviteServlet(BaseFederationServerServlet): 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 530b3f33e2..35da4781f8 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -59,7 +59,15 @@ 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.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 @@ class FederationHandler: 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 @@ class FederationHandler: 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 9bbb538f18..593b8a1d34 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