diff --git a/changelog.d/19960.bugfix b/changelog.d/19960.bugfix new file mode 100644 index 00000000000..c2af8dd7838 --- /dev/null +++ b/changelog.d/19960.bugfix @@ -0,0 +1 @@ +Fix a bug where `/sync` could return a 500 error ("Missing state for event that is not user's own membership") when an out-of-band membership event, since de-outliered into the room's current state, appeared in a room timeline. diff --git a/synapse/visibility.py b/synapse/visibility.py index 4eeba14a6c5..5b8e180f9ac 100644 --- a/synapse/visibility.py +++ b/synapse/visibility.py @@ -196,21 +196,17 @@ def allowed(event: EventBase) -> FilteredEvent | None: # Filter out call invites in public rooms, as this would potentially # ring a lot of users. if event.type == EventTypes.CallInvite and not event.is_state(): - # `state_after_event` should only be None if the event is an outlier, - # and earlier code should filter out outliers entirely. - # - # In addition, we only create outliers locally for out-of-band - # invite rejections, invites received over federation, or state - # events needed to authorise other events. None of this applies to - # call invites. - assert state_after_event is not None - - room_join_rules = state_after_event.get((EventTypes.JoinRules, "")) - if ( - room_join_rules is not None - and room_join_rules.content.get("join_rule") == JoinRules.PUBLIC - ): - return None + # `state_after_event` should only be None if the event is an outlier + # (or our copy of the event is stale and still flagged as one, see + # below); in either case we can't check the join rules, so let the + # event through. + if state_after_event is not None: + room_join_rules = state_after_event.get((EventTypes.JoinRules, "")) + if ( + room_join_rules is not None + and room_join_rules.content.get("join_rule") == JoinRules.PUBLIC + ): + return None # Annotate the event with the user's membership after the event. # @@ -224,8 +220,23 @@ def allowed(event: EventBase) -> FilteredEvent | None: elif state_after_event is not None: user_membership_event = state_after_event.get((EventTypes.Member, user_id)) else: - # unreachable! - raise Exception("Missing state for event that is not user's own membership") + # We have no state for this event: it was flagged as an outlier + # when we fetched the state above, but `always_include_ids` let it + # through anyway. This can happen when an out-of-band membership + # event (e.g. an invite, or a rejection of one, received while not + # in the room) is later de-outliered — because we are also in the + # room and received it over federation — and enters the current + # state, while the copy of the event we are working with (e.g. from + # a worker's not-yet-invalidated cache) still carries the outlier + # flag. We don't know the user's membership at this point in the + # DAG, which MSC4115 permits — return the event unannotated rather + # than failing the whole request. + logger.info( + "filter_and_transform_events_for_client: no state for always-included" + " outlier %s; returning it without a membership annotation", + event.event_id, + ) + return FilteredEvent(event=filtered, membership=None) user_membership = ( user_membership_event.membership diff --git a/tests/test_visibility.py b/tests/test_visibility.py index 9a5efbdd399..257e6feeda4 100644 --- a/tests/test_visibility.py +++ b/tests/test_visibility.py @@ -665,6 +665,31 @@ def test_out_of_band_invite_rejection(self) -> None: [], ) + # ... unless the events are in `always_include_ids`. This mirrors what + # happens when an out-of-band membership event is later de-outliered + # (because we are also in the room and received it over federation) and + # enters the current state — which sync passes as `always_include_ids` + # — while the copy of the event being filtered still carries the + # outlier flag. We can't compute the requesting user's membership at + # such an event (we have no state for it), but it must be let through + # without a membership annotation rather than failing the whole + # request. + # + # Regression test for https://github.com/element-hq/synapse/issues/19858. + filtered_events = self.get_success( + filter_and_transform_events_for_client( + self.hs.get_storage_controllers(), + "@other:test", + [invite_event, reject_event], + always_include_ids=frozenset({invite_event_id}), + ) + ) + self.assertEqual( + [e.event.event_id for e in filtered_events], + [invite_event_id], + ) + self.assertEqual([e.membership for e in filtered_events], [None]) + async def inject_visibility_event( hs: HomeServer,