Skip to content

Commit 80717a2

Browse files
MSC4311: send full PDUs in invite_room_state/knock_room_state over
1 parent 76b4fdc commit 80717a2

9 files changed

Lines changed: 272 additions & 34 deletions

File tree

changelog.d/19749.feature

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Partial [MSC4311](https://github.com/matrix-org/matrix-spec-proposals/pull/4311) implementation: `invite_room_state` and `knock_room_state` are now exchanged
2+
as full PDUs over federation, allowing receiving servers to verify room IDs. Contributed by @FrenchGithubUser @Famedly.

synapse/events/utils.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,15 +1019,6 @@ def strip_event(event: EventBase) -> JsonDict:
10191019
Stripped state events can only have the `sender`, `type`, `state_key` and `content`
10201020
properties present.
10211021
"""
1022-
# MSC4311: Ensure the create event is available on invites and knocks.
1023-
# TODO: Implement the rest of MSC4311
1024-
if (
1025-
event.room_version.msc4291_room_ids_as_hashes
1026-
and event.type == EventTypes.Create
1027-
and event.get_state_key() == ""
1028-
):
1029-
return event.get_pdu_json()
1030-
10311022
return {
10321023
"type": event.type,
10331024
"state_key": event.state_key,
@@ -1036,6 +1027,22 @@ def strip_event(event: EventBase) -> JsonDict:
10361027
}
10371028

10381029

1030+
def strip_event_dict(pdu_dict: JsonDict) -> JsonDict:
1031+
"""Strip a PDU dict to stripped state format (4 fields only).
1032+
1033+
Used to convert full PDUs received over federation (MSC4311) to the
1034+
stripped state format expected by the Client-Server API.
1035+
1036+
Callers must pre-filter to ensure pdu_dict has a non-empty "type" key.
1037+
"""
1038+
return {
1039+
"type": pdu_dict["type"],
1040+
"state_key": pdu_dict.get("state_key", ""),
1041+
"content": pdu_dict.get("content", {}),
1042+
"sender": pdu_dict.get("sender", ""),
1043+
}
1044+
1045+
10391046
def parse_stripped_state_event(raw_stripped_event: Any) -> StrippedStateEvent | None:
10401047
"""
10411048
Given a raw value from an event's `unsigned` field, attempt to parse it into a

synapse/federation/federation_client.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
from synapse.logging.opentracing import SynapseTags, log_kv, set_tag, tag_args, trace
7373
from synapse.metrics import SERVER_NAME_LABEL
7474
from synapse.types import JsonDict, StrCollection, UserID, get_domain_from_id
75+
from synapse.types.state import StateFilter
7576
from synapse.util.async_helpers import concurrently_execute
7677
from synapse.util.caches.expiringcache import ExpiringCache
7778
from synapse.util.duration import Duration
@@ -1350,6 +1351,27 @@ async def _do_send_invite(
13501351
"""
13511352
time_now = self._clock.time_msec()
13521353

1354+
stripped = pdu.unsigned.get("invite_room_state", [])
1355+
if stripped and pdu.room_version.msc4291_room_ids_as_hashes:
1356+
# MSC4311: upgrade stripped state to full PDUs so the receiving server
1357+
# can verify the room ID. Use the stripped state as a guide for which
1358+
# events to look up.
1359+
types = [
1360+
(e.get("type", ""), e.get("state_key", ""))
1361+
for e in stripped
1362+
if isinstance(e, dict) and e.get("type")
1363+
]
1364+
state_filter = StateFilter.from_types(types)
1365+
state_ids = await self.store.get_partial_filtered_current_state_ids(
1366+
pdu.room_id, state_filter
1367+
)
1368+
state_events = await self.store.get_events(state_ids.values())
1369+
invite_room_state_pdus: list = [
1370+
e.get_pdu_json() for e in state_events.values()
1371+
]
1372+
else:
1373+
invite_room_state_pdus = stripped
1374+
13531375
try:
13541376
return await self.transport_layer.send_invite_v2(
13551377
destination=destination,
@@ -1358,7 +1380,7 @@ async def _do_send_invite(
13581380
content={
13591381
"event": pdu.get_pdu_json(time_now),
13601382
"room_version": room_version.identifier,
1361-
"invite_room_state": pdu.unsigned.get("invite_room_state", []),
1383+
"invite_room_state": invite_room_state_pdus,
13621384
},
13631385
)
13641386
except HttpResponseException as e:

synapse/federation/federation_server.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -957,19 +957,22 @@ async def on_send_knock_request(
957957
Returns:
958958
The stripped room state.
959959
"""
960-
_, context = await self._on_send_membership_event(
960+
event, context = await self._on_send_membership_event(
961961
origin, content, Membership.KNOCK, room_id
962962
)
963963

964-
# Retrieve stripped state events from the room and send them back to the remote
965-
# server. This will allow the remote server's clients to display information
966-
# related to the room while the knock request is pending.
967-
stripped_room_state = (
968-
await self.store.get_stripped_room_state_from_event_context(
964+
if event.room_version.msc4291_room_ids_as_hashes:
965+
# MSC4311: return full PDUs so the knocking server can validate the room ID.
966+
knock_room_state = await self.store.get_room_state_pdus_from_event_context(
969967
context, self._room_prejoin_state_types
970968
)
971-
)
972-
return {"knock_room_state": stripped_room_state}
969+
else:
970+
knock_room_state = (
971+
await self.store.get_stripped_room_state_from_event_context(
972+
context, self._room_prejoin_state_types
973+
)
974+
)
975+
return {"knock_room_state": knock_room_state}
973976

974977
async def _on_send_membership_event(
975978
self, origin: str, content: JsonDict, membership_type: str, room_id: str

synapse/federation/transport/server/federation.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@
2828
Sequence,
2929
)
3030

31-
from synapse.api.constants import Direction, EduTypes
31+
from synapse.api.constants import Direction, EduTypes, EventTypes
3232
from synapse.api.errors import Codes, SynapseError
33-
from synapse.api.room_versions import RoomVersions
33+
from synapse.api.room_versions import KNOWN_ROOM_VERSIONS, RoomVersions
3434
from synapse.api.urls import FEDERATION_UNSTABLE_PREFIX, FEDERATION_V2_PREFIX
35+
from synapse.events.utils import strip_event_dict
3536
from synapse.federation.transport.server._base import (
3637
Authenticator,
3738
BaseFederationServlet,
@@ -522,9 +523,29 @@ async def on_PUT(
522523
if not isinstance(invite_room_state, list):
523524
invite_room_state = []
524525

525-
# Synapse expects invite_room_state to be in unsigned, as it is in v1
526-
# API
527-
526+
room_version_obj = KNOWN_ROOM_VERSIONS.get(room_version)
527+
if room_version_obj and room_version_obj.msc4291_room_ids_as_hashes:
528+
# MSC4311: invite_room_state contains full PDUs. Warn if m.room.create
529+
# is absent, then strip to 4-field format for C-S API storage.
530+
create_event_present = any(
531+
isinstance(e, dict)
532+
and e.get("type") == EventTypes.Create
533+
and e.get("state_key") == ""
534+
for e in invite_room_state
535+
)
536+
if not create_event_present:
537+
logger.warning(
538+
"invite_room_state from %s for room %s is missing m.room.create",
539+
origin,
540+
room_id,
541+
)
542+
invite_room_state = [
543+
strip_event_dict(e)
544+
for e in invite_room_state
545+
if isinstance(e, dict) and e.get("type")
546+
]
547+
548+
# Synapse expects invite_room_state to be in unsigned, as it is in v1 API
528549
event.setdefault("unsigned", {})["invite_room_state"] = invite_room_state
529550

530551
result = await self.handler.on_invite_request(

synapse/handlers/federation.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
from synapse.event_auth import validate_event_for_room_version
6060
from synapse.events import EventBase
6161
from synapse.events.snapshot import EventContext, UnpersistedEventContextBase
62+
from synapse.events.utils import strip_event_dict
6263
from synapse.events.validator import EventValidator
6364
from synapse.federation.federation_client import InvalidResponseError
6465
from synapse.handlers.pagination import PURGE_PAGINATION_LOCK_NAME
@@ -865,19 +866,37 @@ async def do_knock(
865866
# further information about the room in the form of partial state events
866867
knock_response = await self.federation_client.send_knock(target_hosts, event)
867868

868-
# Store any stripped room state events in the "unsigned" key of the event.
869+
# Store room state events in the "unsigned" key of the event.
869870
# This is a bit of a hack and is cribbing off of invites. Basically we
870871
# store the room state here and retrieve it again when this event appears
871872
# in the invitee's sync stream. It is stripped out for all other local users.
872-
stripped_room_state = knock_response.get("knock_room_state")
873+
knock_room_state = knock_response.get("knock_room_state")
873874

874-
if stripped_room_state is None:
875+
if knock_room_state is None:
875876
raise KeyError("Missing 'knock_room_state' field in send_knock response")
876877

877-
if not isinstance(stripped_room_state, list):
878+
if not isinstance(knock_room_state, list):
878879
raise TypeError("'knock_room_state' has wrong type")
879880

880-
event.unsigned["knock_room_state"] = stripped_room_state
881+
# MSC4311: knock_room_state contains full PDUs over federation.
882+
# Validate that m.room.create is present, then strip to stripped state for clients.
883+
create_event_present = any(
884+
isinstance(e, dict)
885+
and e.get("type") == EventTypes.Create
886+
and e.get("state_key") == ""
887+
for e in knock_room_state
888+
)
889+
if not create_event_present:
890+
logger.warning(
891+
"knock_room_state for room %s is missing m.room.create event",
892+
event.room_id,
893+
)
894+
895+
event.unsigned["knock_room_state"] = [
896+
strip_event_dict(e)
897+
for e in knock_room_state
898+
if isinstance(e, dict) and e.get("type")
899+
]
881900

882901
context = EventContext.for_outlier(self._storage_controllers)
883902
stream_id = await self._federation_event_handler.persist_events_and_notify(

synapse/storage/databases/main/events_worker.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,6 +1151,42 @@ async def get_stripped_room_state_from_event_context(
11511151

11521152
return [strip_event(e) for e in state_to_include.values()]
11531153

1154+
async def get_room_state_pdus_from_event_context(
1155+
self,
1156+
context: EventContext,
1157+
state_keys_to_include: StateFilter,
1158+
membership_user_id: str | None = None,
1159+
) -> list[JsonDict]:
1160+
"""
1161+
Retrieve room state events as full PDUs, for use in federation
1162+
invite_room_state and knock_room_state (MSC4311).
1163+
1164+
Args:
1165+
context: The event context to retrieve state of the room from.
1166+
state_keys_to_include: The state events to include, for each event type.
1167+
membership_user_id: An optional user ID to include the membership state
1168+
events of.
1169+
1170+
Returns:
1171+
A list of full PDU dicts representing the room state.
1172+
"""
1173+
if membership_user_id:
1174+
types = chain(
1175+
state_keys_to_include.to_types(),
1176+
[(EventTypes.Member, membership_user_id)],
1177+
)
1178+
state_filter = StateFilter.from_types(types)
1179+
else:
1180+
state_filter = state_keys_to_include
1181+
selected_state_ids = await context.get_current_state_ids(state_filter)
1182+
1183+
assert selected_state_ids is not None
1184+
1185+
selected_state_ids = state_filter.filter_state(selected_state_ids)
1186+
state_to_include = await self.get_events(selected_state_ids.values())
1187+
1188+
return [e.get_pdu_json() for e in state_to_include.values()]
1189+
11541190
def _maybe_start_fetch_thread(self) -> None:
11551191
"""Starts an event fetch thread if we are not yet at the maximum number."""
11561192
with self._event_fetch_lock:

tests/federation/test_federation_server.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from synapse.util.clock import Clock
4444

4545
from tests import unittest
46+
from tests.server import FakeChannel
4647
from tests.unittest import override_config
4748

4849
logger = logging.getLogger(__name__)
@@ -714,6 +715,132 @@ def test_send_join_contributes_to_room_join_rate_limit_and_is_limited(self) -> N
714715
# is probably sufficient to reassure that the bucket is updated.
715716

716717

718+
class MSC4311FederationInviteTestCase(unittest.FederatingHomeserverTestCase):
719+
"""MSC4311: Tests for invite_room_state validation and stripping over federation."""
720+
721+
servlets = [
722+
admin.register_servlets,
723+
room.register_servlets,
724+
login.register_servlets,
725+
]
726+
727+
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
728+
super().prepare(reactor, clock, hs)
729+
self.local_user = self.register_user("user", "pass")
730+
self.remote_room_id = f"!room:{self.OTHER_SERVER_NAME}"
731+
self.remote_sender = f"@creator:{self.OTHER_SERVER_NAME}"
732+
733+
def _make_invite_request(
734+
self,
735+
invite_room_state: list,
736+
room_version: str = RoomVersions.V10.identifier,
737+
) -> FakeChannel:
738+
rv = KNOWN_ROOM_VERSIONS[room_version]
739+
room_create_event = make_event_from_dict(
740+
self.add_hashes_and_signatures_from_other_server(
741+
{
742+
"room_id": self.remote_room_id,
743+
"sender": self.remote_sender,
744+
"depth": 1,
745+
"origin_server_ts": 1,
746+
"type": EventTypes.Create,
747+
"state_key": "",
748+
"content": {
749+
"creator": self.remote_sender,
750+
"room_version": room_version,
751+
},
752+
"auth_events": [],
753+
"prev_events": [],
754+
},
755+
rv,
756+
),
757+
rv,
758+
)
759+
invite_event = make_event_from_dict(
760+
self.add_hashes_and_signatures_from_other_server(
761+
{
762+
"room_id": self.remote_room_id,
763+
"sender": self.remote_sender,
764+
"depth": 2,
765+
"origin_server_ts": 2,
766+
"type": EventTypes.Member,
767+
"state_key": self.local_user,
768+
"content": {"membership": Membership.INVITE},
769+
"auth_events": [room_create_event.event_id],
770+
"prev_events": [room_create_event.event_id],
771+
},
772+
rv,
773+
),
774+
rv,
775+
)
776+
return self.make_signed_federation_request(
777+
"PUT",
778+
f"/_matrix/federation/v2/invite/{self.remote_room_id}/{invite_event.event_id}",
779+
content={
780+
"event": invite_event.get_dict(),
781+
"invite_room_state": invite_room_state,
782+
"room_version": room_version,
783+
},
784+
)
785+
786+
def test_full_pdus_stripped_for_client(self) -> None:
787+
"""invite_room_state full PDUs are stripped to 4 fields for the C-S API."""
788+
rv = KNOWN_ROOM_VERSIONS[RoomVersions.V10.identifier]
789+
create_pdu = make_event_from_dict(
790+
self.add_hashes_and_signatures_from_other_server(
791+
{
792+
"room_id": self.remote_room_id,
793+
"sender": self.remote_sender,
794+
"depth": 1,
795+
"origin_server_ts": 1,
796+
"type": EventTypes.Create,
797+
"state_key": "",
798+
"content": {
799+
"creator": self.remote_sender,
800+
"room_version": RoomVersions.V10.identifier,
801+
},
802+
"auth_events": [],
803+
"prev_events": [],
804+
},
805+
rv,
806+
),
807+
rv,
808+
)
809+
# A full PDU has signatures, hashes, etc.
810+
self.assertIn("signatures", create_pdu.get_pdu_json())
811+
812+
channel = self._make_invite_request(
813+
invite_room_state=[create_pdu.get_pdu_json()]
814+
)
815+
self.assertEqual(channel.code, 200, channel.json_body)
816+
817+
# Retrieve the stored invite event and verify invite_room_state is stripped.
818+
store = self.hs.get_datastores().main
819+
invite_memberships = self.get_success(
820+
store.get_invited_rooms_for_local_user(self.local_user)
821+
)
822+
self.assertEqual(len(invite_memberships), 1)
823+
invite_event = self.get_success(store.get_event(invite_memberships[0].event_id))
824+
invite_state = invite_event.unsigned.get("invite_room_state", [])
825+
826+
create_events = [e for e in invite_state if e.get("type") == EventTypes.Create]
827+
self.assertEqual(len(create_events), 1)
828+
create = create_events[0]
829+
# Must be stripped state: only these 4 fields
830+
self.assertIn("type", create)
831+
self.assertIn("state_key", create)
832+
self.assertIn("sender", create)
833+
self.assertIn("content", create)
834+
self.assertNotIn("signatures", create)
835+
self.assertNotIn("hashes", create)
836+
self.assertNotIn("auth_events", create)
837+
838+
def test_missing_create_event_warns_but_accepts(self) -> None:
839+
"""invite_room_state without m.room.create is accepted with a warning."""
840+
channel = self._make_invite_request(invite_room_state=[])
841+
self.assertEqual(channel.code, 200, channel.json_body)
842+
843+
717844
class StripUnsignedFromEventsTestCase(unittest.TestCase):
718845
"""
719846
Test to make sure that we handle the raw JSON events from federation carefully and

0 commit comments

Comments
 (0)