Sanitize invite_room_state received over federation

This commit is contained in:
Eric Eastwood
2026-05-04 17:54:06 -05:00
parent f96c0086f7
commit a088aa8089
7 changed files with 210 additions and 11 deletions
+33
View File
@@ -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
+9
View File
@@ -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,
}
+9
View File
@@ -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:
+33 -3
View File
@@ -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)}
@@ -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
+105 -1
View File
@@ -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
+10
View File
@@ -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