From a209c6757f9af0ecbd87f185cbe4fc35bdf9b8e0 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 14 Jul 2026 10:37:52 +0100 Subject: [PATCH 1/3] Return an out-of-band membership event from /context instead of a 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An out-of-band membership event (e.g. an invite or knock received over federation) is an outlier with no stream position or state group, so computing surrounding events or state for it fails with 'No state group for unknown or outlier event'. Visibility already permits the user to see their own out-of-band membership events, and clients legitimately fetch them — e.g. to render a push notification for an invite after the server has already joined the user (auto-accepted knock). Return the event with an empty context instead of erroring. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uk8aPxHn3BHCe52L226jdG --- synapse/handlers/room.py | 19 +++++++++++ tests/handlers/test_federation.py | 53 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index c75fff91706..d3fad7611dd 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -1975,6 +1975,25 @@ async def filter_evts(events: list[EventBase]) -> list[FilteredEvent]: if not filtered: raise AuthError(403, "You don't have permission to access that event.") + if event.internal_metadata.outlier: + # The only outliers that pass the visibility filter are the user's own + # out-of-band membership events (e.g. an invite or knock received over + # federation). They have no stream position or state group, so we cannot + # compute surrounding events or state — return the event with an empty + # context rather than failing. Clients legitimately fetch such events, + # e.g. to render a push notification for an invite after the room has + # already been joined. + token = await StreamToken.START.to_string(self.store) + return EventContext( + events_before=[], + event=filtered[0], + events_after=[], + state=[], + aggregations={}, + start=token, + end=token, + ) + results = await self.store.get_events_around( room_id, event_id, before_limit, after_limit, event_filter ) diff --git a/tests/handlers/test_federation.py b/tests/handlers/test_federation.py index 0c7edbaa2da..cca341873ed 100644 --- a/tests/handlers/test_federation.py +++ b/tests/handlers/test_federation.py @@ -359,6 +359,59 @@ def create_invite() -> EventBase: exc=LimitExceededError, ) + def test_context_on_out_of_band_invite(self) -> None: + """/context on the user's own out-of-band invite returns the event with an + empty context instead of a 500: the outlier has no stream position or state + group, so surrounding events and state cannot be computed. Clients fetch such + events e.g. to render a push notification for an invite after the server has + already joined the user (auto-accepted knock).""" + other_server = "otherserver" + other_user = "@otheruser:" + other_server + + user_id = self.register_user("kermit", "test") + tok = self.login("kermit", "test") + room_id = self.helper.create_room_as(room_creator=user_id, tok=tok) + room_version = self.get_success(self.store.get_room_version(room_id)) + + invited_user = self.register_user("invitee", "test") + invited_tok = self.login("invitee", "test") + + invite = make_test_pdu_event( + { + "type": EventTypes.Member, + "content": {"membership": "invite"}, + "room_id": room_id, + "sender": other_user, + "state_key": invited_user, + "depth": 32, + "prev_events": [], + "auth_events": [], + "origin_server_ts": self.clock.time_msec(), + }, + room_version, + ) + self.get_success( + self.handler.on_invite_request(other_server, invite, invite.room_version) + ) + + channel = self.make_request( + "GET", + f"/rooms/{room_id}/context/{invite.event_id}", + access_token=invited_tok, + ) + self.assertEqual(channel.code, 200, channel.result) + self.assertEqual(channel.json_body["event"]["event_id"], invite.event_id) + self.assertEqual(channel.json_body["events_before"], []) + self.assertEqual(channel.json_body["events_after"], []) + + # Another user must NOT be able to fetch it. + channel = self.make_request( + "GET", + f"/rooms/{room_id}/context/{invite.event_id}", + access_token=tok, + ) + self.assertEqual(channel.code, 403, channel.result) + def _build_and_send_join_event( self, other_server: str, other_user: str, room_id: str ) -> EventBase: From 74e222846128148dc186074b95104e753bb6d2df Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 14 Jul 2026 17:09:12 +0100 Subject: [PATCH 2/3] changelog --- changelog.d/19959.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/19959.bugfix diff --git a/changelog.d/19959.bugfix b/changelog.d/19959.bugfix new file mode 100644 index 00000000000..5bca59c9b38 --- /dev/null +++ b/changelog.d/19959.bugfix @@ -0,0 +1 @@ +Don't 500 when calling /context on an out-of-band membership event. From eff11d8264313b0c18b9954c08b3149f6b4b96c0 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 14 Jul 2026 17:15:53 +0100 Subject: [PATCH 3/3] Fix mypy: don't shadow the later StreamToken local Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uk8aPxHn3BHCe52L226jdG --- synapse/handlers/room.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index d3fad7611dd..7c05c70b089 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -1983,15 +1983,15 @@ async def filter_evts(events: list[EventBase]) -> list[FilteredEvent]: # context rather than failing. Clients legitimately fetch such events, # e.g. to render a push notification for an invite after the room has # already been joined. - token = await StreamToken.START.to_string(self.store) + dummy_token = await StreamToken.START.to_string(self.store) return EventContext( events_before=[], event=filtered[0], events_after=[], state=[], aggregations={}, - start=token, - end=token, + start=dummy_token, + end=dummy_token, ) results = await self.store.get_events_around(