diff --git a/changelog.d/18540.feature b/changelog.d/18540.feature new file mode 100644 index 00000000000..2f1910c9e38 --- /dev/null +++ b/changelog.d/18540.feature @@ -0,0 +1 @@ +Add support for [MSC4293](https://github.com/matrix-org/matrix-spec-proposals/pull/4293) - Redact on Kick/Ban. \ No newline at end of file diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py index 1b7474034f1..c4181a6b0b3 100644 --- a/synapse/config/experimental.py +++ b/synapse/config/experimental.py @@ -582,6 +582,9 @@ def read_config( # MSC4155: Invite filtering self.msc4155_enabled: bool = experimental.get("msc4155_enabled", False) + # MSC4293: Redact on Kick/Ban + self.msc4293_enabled: bool = experimental.get("msc4293_enabled", False) + # MSC4306: Thread Subscriptions # (and MSC4308: sliding sync extension for thread subscriptions) self.msc4306_enabled: bool = experimental.get("msc4306_enabled", False) diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py index 6b0deda0dfe..09b5de275ce 100644 --- a/synapse/rest/client/room.py +++ b/synapse/rest/client/room.py @@ -1100,6 +1100,7 @@ def __init__(self, hs: "HomeServer"): super().__init__(hs) self.room_member_handler = hs.get_room_member_handler() self.auth = hs.get_auth() + self.config = hs.config def register(self, http_server: HttpServer) -> None: # /rooms/$roomid/[join|invite|leave|ban|unban|kick] @@ -1123,12 +1124,12 @@ async def _do( }: raise AuthError(403, "Guest access not allowed") - content = parse_json_object_from_request(request, allow_empty_body=True) + request_body = parse_json_object_from_request(request, allow_empty_body=True) if membership_action == "invite" and all( - key in content for key in ("medium", "address") + key in request_body for key in ("medium", "address") ): - if not all(key in content for key in ("id_server", "id_access_token")): + if not all(key in request_body for key in ("id_server", "id_access_token")): raise SynapseError( HTTPStatus.BAD_REQUEST, "`id_server` and `id_access_token` are required when doing 3pid invite", @@ -1139,12 +1140,12 @@ async def _do( await self.room_member_handler.do_3pid_invite( room_id, requester.user, - content["medium"], - content["address"], - content["id_server"], + request_body["medium"], + request_body["address"], + request_body["id_server"], requester, txn_id, - content["id_access_token"], + request_body["id_access_token"], ) except ShadowBanError: # Pretend the request succeeded. @@ -1153,12 +1154,19 @@ async def _do( target = requester.user if membership_action in ["invite", "ban", "unban", "kick"]: - assert_params_in_dict(content, ["user_id"]) - target = UserID.from_string(content["user_id"]) + assert_params_in_dict(request_body, ["user_id"]) + target = UserID.from_string(request_body["user_id"]) event_content = None - if "reason" in content: - event_content = {"reason": content["reason"]} + if "reason" in request_body: + event_content = {"reason": request_body["reason"]} + if self.config.experimental.msc4293_enabled: + if "org.matrix.msc4293.redact_events" in request_body: + if event_content is None: + event_content = {} + event_content["org.matrix.msc4293.redact_events"] = request_body[ + "org.matrix.msc4293.redact_events" + ] try: await self.room_member_handler.update_membership( @@ -1167,7 +1175,7 @@ async def _do( room_id=room_id, action=membership_action, txn_id=txn_id, - third_party_signed=content.get("third_party_signed", None), + third_party_signed=request_body.get("third_party_signed", None), content=event_content, ) except ShadowBanError: diff --git a/synapse/storage/databases/main/events.py b/synapse/storage/databases/main/events.py index 741146417f9..c0299cb62e8 100644 --- a/synapse/storage/databases/main/events.py +++ b/synapse/storage/databases/main/events.py @@ -377,11 +377,130 @@ async def _persist_events_and_state_updates( event_counter.labels(event.type, origin_type, origin_entity).inc() + if ( + not self.hs.config.experimental.msc4293_enabled + or event.type != EventTypes.Member + or event.state_key is None + ): + continue + + # check if this is an unban/join that will undo a ban/kick redaction for + # a user in the room + if event.membership in [Membership.LEAVE, Membership.JOIN]: + if ( + event.membership == Membership.LEAVE + and event.sender == event.state_key + ): + # self-leave, ignore + continue + + # if there is an existing ban/leave causing redactions for + # this user/room combination update the entry with the stream + # ordering when the redactions should stop - in the case of a backfilled + # event where the stream ordering is negative, use the current max stream + # ordering + stream_ordering = event.internal_metadata.stream_ordering + assert stream_ordering is not None + if stream_ordering < 0: + stream_ordering = self._stream_id_gen.get_current_token() + await self.db_pool.simple_update( + "room_ban_redactions", + {"room_id": event.room_id, "user_id": event.state_key}, + {"redact_end_ordering": stream_ordering}, + desc="room_ban_redactions update redact_end_ordering", + ) + + # check for msc4293 redact_events flag and apply if found + if event.membership not in [Membership.LEAVE, Membership.BAN]: + continue + redact = event.content.get("org.matrix.msc4293.redact_events", False) + if not redact or not isinstance(redact, bool): + continue + # self-bans currently are not authorized so we don't check for that + # case + if ( + event.membership == Membership.BAN + and event.sender == event.state_key + ): + continue + + # check that sender can redact + redact_allowed = await self._can_sender_redact(event) + + # Signal that this user's past events in this room + # should be redacted by adding an entry to + # `room_ban_redactions`. + if redact_allowed: + await self.db_pool.simple_upsert( + "room_ban_redactions", + {"room_id": event.room_id, "user_id": event.state_key}, + { + "redacting_event_id": event.event_id, + "redact_end_ordering": None, + }, + { + "room_id": event.room_id, + "user_id": event.state_key, + "redacting_event_id": event.event_id, + "redact_end_ordering": None, + }, + ) + + # normally the cache entry for a redacted event would be invalidated + # by an arriving redaction event, but since we are not creating redaction + # events we invalidate manually + self.store._invalidate_local_get_event_cache_room_id(event.room_id) + + self.store._invalidate_async_get_event_cache_room_id(event.room_id) + if new_forward_extremities: self.store.get_latest_event_ids_in_room.prefill( (room_id,), frozenset(new_forward_extremities) ) + async def _can_sender_redact(self, event: EventBase) -> bool: + state_filter = StateFilter.from_types( + [(EventTypes.PowerLevels, ""), (EventTypes.Create, "")] + ) + state = await self.store.get_partial_filtered_current_state_ids( + event.room_id, state_filter + ) + pl_id = state[(EventTypes.PowerLevels, "")] + pl_event = await self.store.get_event(pl_id, allow_none=True) + + if pl_event is None: + # per the spec, if a power level event isn't in the room, grant the creator + # level 100 and all other users 0 + create_id = state[(EventTypes.Create, "")] + create_event = await self.store.get_event(create_id, allow_none=True) + if create_event is None: + # not sure how this would happen but if it does then just deny the redaction + logger.warning("No create event found for room %s", event.room_id) + return False + if create_event.sender == event.sender: + return True + + assert pl_event is not None + sender_level = pl_event.content.get("users", {}).get(event.sender) + if sender_level is None: + sender_level = pl_event.content.get("users_default", 0) + + redact_level = pl_event.content.get("redact") + if redact_level is None: + redact_level = pl_event.content.get("events_default", 0) + + room_redaction_level = pl_event.content.get("events", {}).get( + "m.room.redaction" + ) + if room_redaction_level is not None: + if sender_level < room_redaction_level: + return False + + if sender_level >= redact_level: + return True + + return False + async def _calculate_sliding_sync_table_changes( self, room_id: str, diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py index d9ef93f8265..7927842d0f0 100644 --- a/synapse/storage/databases/main/events_worker.py +++ b/synapse/storage/databases/main/events_worker.py @@ -17,7 +17,7 @@ # [This file includes modifications made by New Vector Limited] # # - +import json import logging import threading import weakref @@ -976,6 +976,13 @@ def _invalidate_local_get_event_cache_room_id(self, room_id: str) -> None: self._event_ref.clear() self._current_event_fetches.clear() + def _invalidate_async_get_event_cache_room_id(self, room_id: str) -> None: + """ + Clears the async get_event cache for a room. Currently a no-op until + an async get_event cache is implemented - see https://github.com/matrix-org/synapse/pull/13242 + for preliminary work. + """ + async def _get_events_from_cache( self, events: Iterable[str], update_metrics: bool = True ) -> Dict[str, EventCacheEntry]: @@ -1575,6 +1582,44 @@ def _fetch_event_rows( if d: d.redactions.append(redacter) + # check for MSC4932 redactions + to_check = [] + events: List[_EventRow] = [] + for e in evs: + event = event_dict.get(e) + if not event: + continue + events.append(event) + event_json = json.loads(event.json) + room_id = event_json.get("room_id") + user_id = event_json.get("sender") + to_check.append((room_id, user_id)) + + # likely that some of these events may be for the same room/user combo, in + # which case we don't need to do redundant queries + to_check_set = set(to_check) + for room_and_user in to_check_set: + room_redactions_sql = "SELECT redacting_event_id, redact_end_ordering FROM room_ban_redactions WHERE room_id = ? and user_id = ?" + txn.execute(room_redactions_sql, room_and_user) + + res = txn.fetchone() + # we have a redaction for a room, user_id combo - apply it to matching events + if not res: + continue + for e_row in events: + e_json = json.loads(e_row.json) + room_id = e_json.get("room_id") + user_id = e_json.get("sender") + if room_and_user != (room_id, user_id): + continue + redacting_event_id, redact_end_ordering = res + if redact_end_ordering: + # Avoid redacting any events arriving *after* the membership event which + # ends an active redaction - note that this will always redact + # backfilled events, as they have a negative stream ordering + if e_row.stream_ordering >= redact_end_ordering: + continue + e_row.redactions.append(redacting_event_id) return event_dict def _maybe_redact_event_row( diff --git a/synapse/storage/schema/main/delta/92/08_room_ban_redactions.sql b/synapse/storage/schema/main/delta/92/08_room_ban_redactions.sql new file mode 100644 index 00000000000..566ddcbdd7a --- /dev/null +++ b/synapse/storage/schema/main/delta/92/08_room_ban_redactions.sql @@ -0,0 +1,21 @@ +-- +-- This file is licensed under the Affero General Public License (AGPL) version 3. +-- +-- Copyright (C) 2025 New Vector, Ltd +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as +-- published by the Free Software Foundation, either version 3 of the +-- License, or (at your option) any later version. +-- +-- See the GNU Affero General Public License for more details: +-- . + +CREATE TABLE room_ban_redactions( + room_id text NOT NULL, + user_id text NOT NULL, + redacting_event_id text NOT NULL, + redact_end_ordering bigint DEFAULT NULL, -- stream ordering after which redactions are not applied + CONSTRAINT room_ban_redaction_uniqueness UNIQUE (room_id, user_id) +); + diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 48d33b8e17c..4bfd95f2cb3 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -43,8 +43,9 @@ RoomTypes, ) from synapse.api.errors import Codes, HttpResponseException +from synapse.api.room_versions import RoomVersions from synapse.appservice import ApplicationService -from synapse.events import EventBase +from synapse.events import EventBase, make_event_from_dict from synapse.events.snapshot import EventContext from synapse.rest import admin from synapse.rest.client import ( @@ -4499,3 +4500,985 @@ def test_sending_event_and_leaving_does_not_record_participation( self.store.get_room_participation(self.user2, self.room1) ) self.assertFalse(participant) + + +class MSC4293RedactOnBanKickTestCase(unittest.FederatingHomeserverTestCase): + servlets = [ + synapse.rest.admin.register_servlets_for_client_rest_resource, + room.register_servlets, + login.register_servlets, + admin.register_servlets, + ] + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + super().prepare(reactor, clock, hs) + self.creator = self.register_user("creator", "test") + self.creator_tok = self.login("creator", "test") + + self.bad_user_id = self.register_user("bad", "test") + self.bad_tok = self.login("bad", "test") + + self.room_id = self.helper.create_room_as(self.creator, tok=self.creator_tok) + + self.store = hs.get_datastores().main + self._storage_controllers = hs.get_storage_controllers() + + self.federation_event_handler = self.hs.get_federation_event_handler() + + self.hs.config.experimental.msc4293_enabled = True + + def _check_redactions( + self, + original_events: List[EventBase], + pulled_events: List[JsonDict], + expect_redaction: bool, + reason: Optional[str] = None, + ) -> None: + """ + Checks a set of original events against a second set of the same events, pulled + from the /messages api. If expect_redaction is true, we expect that the second + set of events will be redacted, and the test will fail if that is not the case. + Otherwise, verifies that the events have not been redacted and fails if not. + + Args: + original_events: A list of the original events sent + pulled_events: A list of the same events as the orignal events, fetched + over the /messages api + expect_redaction: Whether or not the pulled_events should be redacted + reason: If the events are expected to be redacted, the expected reason + for the redaction + + """ + if expect_redaction: + redacted_count = 0 + for pulled_event in pulled_events: + for old_event in original_events: + if pulled_event["event_id"] != old_event.event_id: + continue + # we have a matching event, check that it is redacted + event_content = pulled_event["content"] + if event_content: + self.fail(f"Expected event {pulled_event} to be redacted") + redacting_event = pulled_event.get("redacted_because") + if not redacting_event: + self.fail( + f"Expected event {pulled_event} to have a redacting event." + ) + # check that the redacting event records the expected reason, and the + # redact_events flag + content = redacting_event["content"] + self.assertEqual(content["reason"], reason) + self.assertEqual(content["org.matrix.msc4293.redact_events"], True) + redacted_count += 1 + # all provided events should be redacted + self.assertEqual(len(original_events), redacted_count) + else: + unredacted_events = 0 + for pulled_event in pulled_events: + for old_event in original_events: + if pulled_event["event_id"] != old_event.event_id: + continue + # we have a matching event, make sure it is not redacted + redacted_because = pulled_event.get("redacted_because") + if redacted_because: + self.fail("Event should not have been redacted") + self.assertEqual(old_event.content, pulled_event["content"]) + unredacted_events += 1 + # all provided events should not have been redacted + self.assertEqual(unredacted_events, len(original_events)) + + def test_banning_local_member_with_flag_redacts_their_events(self) -> None: + self.helper.join(self.room_id, self.bad_user_id, tok=self.bad_tok) + + # bad user sends some messages + originals = [] + for i in range(5): + event = {"body": f"bothersome noise {i}", "msgtype": "m.text"} + res = self.helper.send_event( + self.room_id, "m.room.message", event, tok=self.bad_tok, expect_code=200 + ) + originals.append(res["event_id"]) + + # grab original events for comparison + original_events = [self.get_success(self.store.get_event(x)) for x in originals] + + # creator bans user with redaction flag set + content = { + "reason": "flooding", + "org.matrix.msc4293.redact_events": True, + } + self.helper.change_membership( + self.room_id, + self.creator, + self.bad_user_id, + "ban", + content, + self.creator_tok, + ) + + filter = json.dumps({"types": [EventTypes.Message]}) + channel = self.make_request( + "GET", + f"rooms/{self.room_id}/messages?filter={filter}&limit=50", + access_token=self.creator_tok, + ) + self.assertEqual(channel.code, 200) + self._check_redactions( + original_events, + channel.json_body["chunk"], + expect_redaction=True, + reason="flooding", + ) + + def test_banning_remote_member_with_flag_redacts_their_events(self) -> None: + bad_user = "@remote_bad_user:" + self.OTHER_SERVER_NAME + channel = self.make_signed_federation_request( + "GET", + f"/_matrix/federation/v1/make_join/{self.room_id}/{bad_user}?ver=10", + ) + self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) + join_result = channel.json_body + + join_event_dict = join_result["event"] + self.add_hashes_and_signatures_from_other_server( + join_event_dict, + RoomVersions.V10, + ) + channel = self.make_signed_federation_request( + "PUT", + f"/_matrix/federation/v2/send_join/{self.room_id}/x", + content=join_event_dict, + ) + self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) + + # the room should show that the bad user is a member + r = self.get_success( + self._storage_controllers.state.get_current_state(self.room_id) + ) + self.assertEqual(r[("m.room.member", bad_user)].membership, "join") + + auth_ids = [ + r[("m.room.create", "")].event_id, + r[("m.room.power_levels", "")].event_id, + r[("m.room.member", "@remote_bad_user:other.example.com")].event_id, + ] + original_messages = [] + for i in range(5): + remote_message = make_event_from_dict( + self.add_hashes_and_signatures_from_other_server( + { + "room_id": self.room_id, + "sender": bad_user, + "depth": 1000, + "origin_server_ts": 1, + "type": "m.room.message", + "content": {"body": f"remote bummer{i}"}, + "auth_events": auth_ids, + "prev_events": auth_ids, + } + ), + room_version=RoomVersions.V10, + ) + + self.get_success( + self.federation_event_handler.on_receive_pdu( + self.OTHER_SERVER_NAME, remote_message + ) + ) + original_messages.append(remote_message) + + # creator bans bad user with redaction flag set + content = { + "reason": "bummer messages", + "org.matrix.msc4293.redact_events": True, + } + res = self.helper.change_membership( + self.room_id, self.creator, bad_user, "ban", content, self.creator_tok + ) + ban_event_id = res["event_id"] + + filter = json.dumps({"types": [EventTypes.Message]}) + channel = self.make_request( + "GET", + f"rooms/{self.room_id}/messages?filter={filter}&limit=50", + access_token=self.creator_tok, + ) + self.assertEqual(channel.code, 200) + self._check_redactions( + original_messages, + channel.json_body["chunk"], + expect_redaction=True, + reason="bummer messages", + ) + + # any future messages that are soft-failed are also redacted - send messages referencing + # dag before ban, they should be soft-failed but also redacted + new_original_messages = [] + for i in range(5): + remote_message = make_event_from_dict( + self.add_hashes_and_signatures_from_other_server( + { + "room_id": self.room_id, + "sender": bad_user, + "depth": 1000, + "origin_server_ts": 1, + "type": "m.room.message", + "content": {"body": f"soft-fail remote bummer{i}"}, + "auth_events": auth_ids, + "prev_events": auth_ids, + } + ), + room_version=RoomVersions.V10, + ) + + self.get_success( + self.federation_event_handler.on_receive_pdu( + self.OTHER_SERVER_NAME, remote_message + ) + ) + new_original_messages.append(remote_message) + + # pull them from the db to check because they should be soft-failed and thus not available over + # cs-api + for message in new_original_messages: + original = self.get_success(self.store.get_event(message.event_id)) + if not original: + self.fail("Expected to find remote message in DB") + redacted_because = original.unsigned.get("redacted_because") + if not redacted_because: + self.fail("Did not find redacted_because field") + self.assertEqual(redacted_because.event_id, ban_event_id) + + def test_unbanning_remote_user_stops_redaction_action(self) -> None: + bad_user = "@remote_bad_user:" + self.OTHER_SERVER_NAME + channel = self.make_signed_federation_request( + "GET", + f"/_matrix/federation/v1/make_join/{self.room_id}/{bad_user}?ver=10", + ) + self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) + join_result = channel.json_body + + join_event_dict = join_result["event"] + self.add_hashes_and_signatures_from_other_server( + join_event_dict, + RoomVersions.V10, + ) + channel = self.make_signed_federation_request( + "PUT", + f"/_matrix/federation/v2/send_join/{self.room_id}/x", + content=join_event_dict, + ) + self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) + + # the room should show that the bad user is a member + r = self.get_success( + self._storage_controllers.state.get_current_state(self.room_id) + ) + self.assertEqual(r[("m.room.member", bad_user)].membership, "join") + + auth_ids = [ + r[("m.room.create", "")].event_id, + r[("m.room.power_levels", "")].event_id, + r[("m.room.member", "@remote_bad_user:other.example.com")].event_id, + ] + original_messages = [] + for i in range(5): + remote_message = make_event_from_dict( + self.add_hashes_and_signatures_from_other_server( + { + "room_id": self.room_id, + "sender": bad_user, + "depth": 1000, + "origin_server_ts": 1, + "type": "m.room.message", + "content": {"body": f"annoying messages {i}"}, + "auth_events": auth_ids, + "prev_events": auth_ids, + } + ), + room_version=RoomVersions.V10, + ) + + self.get_success( + self.federation_event_handler.on_receive_pdu( + self.OTHER_SERVER_NAME, remote_message + ) + ) + original_messages.append(remote_message) + + # creator bans bad user with redaction flag set + content = { + "reason": "this dude sucks", + "org.matrix.msc4293.redact_events": True, + } + self.helper.change_membership( + self.room_id, self.creator, bad_user, "ban", content, self.creator_tok + ) + + filter = json.dumps({"types": [EventTypes.Message]}) + channel = self.make_request( + "GET", + f"rooms/{self.room_id}/messages?filter={filter}&limit=50", + access_token=self.creator_tok, + ) + self.assertEqual(channel.code, 200) + self._check_redactions( + original_messages, + channel.json_body["chunk"], + True, + reason="this dude sucks", + ) + + # unban user + self.helper.change_membership( + self.room_id, self.creator, bad_user, "unban", {}, self.creator_tok + ) + + # user should be able to join again + channel = self.make_signed_federation_request( + "GET", + f"/_matrix/federation/v1/make_join/{self.room_id}/{bad_user}?ver=10", + ) + self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) + join_result = channel.json_body + + join_event_dict = join_result["event"] + self.add_hashes_and_signatures_from_other_server( + join_event_dict, + RoomVersions.V10, + ) + channel = self.make_signed_federation_request( + "PUT", + f"/_matrix/federation/v2/send_join/{self.room_id}/x", + content=join_event_dict, + ) + self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) + + # the room should show that the bad user is a member again + new_state = self.get_success( + self._storage_controllers.state.get_current_state(self.room_id) + ) + self.assertEqual(new_state[("m.room.member", bad_user)].membership, "join") + + new_state = self.get_success( + self._storage_controllers.state.get_current_state(self.room_id) + ) + auth_ids = [ + new_state[("m.room.create", "")].event_id, + new_state[("m.room.power_levels", "")].event_id, + new_state[("m.room.member", "@remote_bad_user:other.example.com")].event_id, + ] + + # messages after unban and join proceed unredacted + new_original_messages = [] + for i in range(5): + remote_message = make_event_from_dict( + self.add_hashes_and_signatures_from_other_server( + { + "room_id": self.room_id, + "sender": bad_user, + "depth": 1000, + "origin_server_ts": 1, + "type": "m.room.message", + "content": {"body": f"no longer a bummer {i}"}, + "auth_events": auth_ids, + "prev_events": auth_ids, + } + ), + room_version=RoomVersions.V10, + ) + + self.get_success( + self.federation_event_handler.on_receive_pdu( + self.OTHER_SERVER_NAME, remote_message + ) + ) + new_original_messages.append(remote_message) + + filter = json.dumps({"types": [EventTypes.Message]}) + channel = self.make_request( + "GET", + f"rooms/{self.room_id}/messages?filter={filter}&limit=50", + access_token=self.creator_tok, + ) + self.assertEqual(channel.code, 200) + self._check_redactions(new_original_messages, channel.json_body["chunk"], False) + + def test_redaction_flag_ignored_for_user_if_banner_lacks_redaction_power( + self, + ) -> None: + # change power levels so creator can ban but not redact + self.helper.send_state( + self.room_id, + "m.room.power_levels", + {"events_default": 0, "redact": 100, "users": {self.creator: 75}}, + tok=self.creator_tok, + ) + self.helper.join(self.room_id, self.bad_user_id, tok=self.bad_tok) + + # bad user sends some messages + original_ids = [] + for i in range(15): + event = {"body": f"being a menace {i}", "msgtype": "m.text"} + res = self.helper.send_event( + self.room_id, "m.room.message", event, tok=self.bad_tok, expect_code=200 + ) + original_ids.append(res["event_id"]) + + # grab original events before ban + originals = [self.get_success(self.store.get_event(x)) for x in original_ids] + + # creator bans bad user with redaction flag + content = { + "reason": "flooding", + "org.matrix.msc4293.redact_events": True, + } + self.helper.change_membership( + self.room_id, + self.creator, + self.bad_user_id, + "ban", + content, + self.creator_tok, + ) + + filter = json.dumps({"types": [EventTypes.Message]}) + channel = self.make_request( + "GET", + f"rooms/{self.room_id}/messages?filter={filter}&limit=50", + access_token=self.creator_tok, + ) + self.assertEqual(channel.code, 200) + # messages are not redacted + self._check_redactions(originals, channel.json_body["chunk"], False) + + def test_kicking_local_member_with_flag_redacts_their_events(self) -> None: + self.helper.join(self.room_id, self.bad_user_id, tok=self.bad_tok) + + # bad user sends some messages + originals = [] + for i in range(5): + event = {"body": f"bothersome noise {i}", "msgtype": "m.text"} + res = self.helper.send_event( + self.room_id, "m.room.message", event, tok=self.bad_tok, expect_code=200 + ) + originals.append(res["event_id"]) + + # grab original events for comparison + original_events = [self.get_success(self.store.get_event(x)) for x in originals] + + # creator kicks user with redaction flag set + content = { + "reason": "flooding", + "org.matrix.msc4293.redact_events": True, + } + self.helper.change_membership( + self.room_id, + self.creator, + self.bad_user_id, + "kick", + content, + self.creator_tok, + ) + + filter = json.dumps({"types": [EventTypes.Message]}) + channel = self.make_request( + "GET", + f"rooms/{self.room_id}/messages?filter={filter}&limit=50", + access_token=self.creator_tok, + ) + self.assertEqual(channel.code, 200) + self._check_redactions( + original_events, + channel.json_body["chunk"], + expect_redaction=True, + reason="flooding", + ) + + def test_kicking_remote_member_with_flag_redacts_their_events(self) -> None: + bad_user = "@remote_bad_user:" + self.OTHER_SERVER_NAME + channel = self.make_signed_federation_request( + "GET", + f"/_matrix/federation/v1/make_join/{self.room_id}/{bad_user}?ver=10", + ) + self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) + join_result = channel.json_body + + join_event_dict = join_result["event"] + self.add_hashes_and_signatures_from_other_server( + join_event_dict, + RoomVersions.V10, + ) + channel = self.make_signed_federation_request( + "PUT", + f"/_matrix/federation/v2/send_join/{self.room_id}/x", + content=join_event_dict, + ) + self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) + + # the room should show that the bad user is a member + r = self.get_success( + self._storage_controllers.state.get_current_state(self.room_id) + ) + self.assertEqual(r[("m.room.member", bad_user)].membership, "join") + + auth_ids = [ + r[("m.room.create", "")].event_id, + r[("m.room.power_levels", "")].event_id, + r[("m.room.member", "@remote_bad_user:other.example.com")].event_id, + ] + original_messages = [] + for i in range(5): + remote_message = make_event_from_dict( + self.add_hashes_and_signatures_from_other_server( + { + "room_id": self.room_id, + "sender": bad_user, + "depth": 1000, + "origin_server_ts": 1, + "type": "m.room.message", + "content": {"body": f"remote bummer{i}"}, + "auth_events": auth_ids, + "prev_events": auth_ids, + } + ), + room_version=RoomVersions.V10, + ) + + self.get_success( + self.federation_event_handler.on_receive_pdu( + self.OTHER_SERVER_NAME, remote_message + ) + ) + original_messages.append(remote_message) + + # creator kicks bad user with redaction flag set + content = { + "reason": "bummer messages", + "org.matrix.msc4293.redact_events": True, + } + res = self.helper.change_membership( + self.room_id, self.creator, bad_user, "kick", content, self.creator_tok + ) + ban_event_id = res["event_id"] + + filter = json.dumps({"types": [EventTypes.Message]}) + channel = self.make_request( + "GET", + f"rooms/{self.room_id}/messages?filter={filter}&limit=50", + access_token=self.creator_tok, + ) + self.assertEqual(channel.code, 200) + self._check_redactions( + original_messages, + channel.json_body["chunk"], + expect_redaction=True, + reason="bummer messages", + ) + + # any future messages that are soft-failed are also redacted - send messages referencing + # dag before ban, they should be soft-failed but also redacted + new_original_messages = [] + for i in range(5): + remote_message = make_event_from_dict( + self.add_hashes_and_signatures_from_other_server( + { + "room_id": self.room_id, + "sender": bad_user, + "depth": 1000, + "origin_server_ts": 1, + "type": "m.room.message", + "content": {"body": f"soft-fail remote bummer{i}"}, + "auth_events": auth_ids, + "prev_events": auth_ids, + } + ), + room_version=RoomVersions.V10, + ) + + self.get_success( + self.federation_event_handler.on_receive_pdu( + self.OTHER_SERVER_NAME, remote_message + ) + ) + new_original_messages.append(remote_message) + + # pull them from the db to check because they should be soft-failed and thus not available over + # cs-api + for message in new_original_messages: + original = self.get_success(self.store.get_event(message.event_id)) + if not original: + self.fail("Expected to find remote message in DB") + self.assertEqual(original.unsigned["redacted_by"], ban_event_id) + + def test_rejoining_kicked_remote_user_stops_redaction_action(self) -> None: + bad_user = "@remote_bad_user:" + self.OTHER_SERVER_NAME + channel = self.make_signed_federation_request( + "GET", + f"/_matrix/federation/v1/make_join/{self.room_id}/{bad_user}?ver=10", + ) + self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) + join_result = channel.json_body + + join_event_dict = join_result["event"] + self.add_hashes_and_signatures_from_other_server( + join_event_dict, + RoomVersions.V10, + ) + channel = self.make_signed_federation_request( + "PUT", + f"/_matrix/federation/v2/send_join/{self.room_id}/x", + content=join_event_dict, + ) + self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) + + # the room should show that the bad user is a member + r = self.get_success( + self._storage_controllers.state.get_current_state(self.room_id) + ) + self.assertEqual(r[("m.room.member", bad_user)].membership, "join") + + auth_ids = [ + r[("m.room.create", "")].event_id, + r[("m.room.power_levels", "")].event_id, + r[("m.room.member", "@remote_bad_user:other.example.com")].event_id, + ] + original_messages = [] + for i in range(5): + remote_message = make_event_from_dict( + self.add_hashes_and_signatures_from_other_server( + { + "room_id": self.room_id, + "sender": bad_user, + "depth": 1000, + "origin_server_ts": 1, + "type": "m.room.message", + "content": {"body": f"annoying messages {i}"}, + "auth_events": auth_ids, + "prev_events": auth_ids, + } + ), + room_version=RoomVersions.V10, + ) + + self.get_success( + self.federation_event_handler.on_receive_pdu( + self.OTHER_SERVER_NAME, remote_message + ) + ) + original_messages.append(remote_message) + + # creator kicks bad user with redaction flag set + content = { + "reason": "this dude sucks", + "org.matrix.msc4293.redact_events": True, + } + self.helper.change_membership( + self.room_id, self.creator, bad_user, "kick", content, self.creator_tok + ) + + filter = json.dumps({"types": [EventTypes.Message]}) + channel = self.make_request( + "GET", + f"rooms/{self.room_id}/messages?filter={filter}&limit=50", + access_token=self.creator_tok, + ) + self.assertEqual(channel.code, 200) + self._check_redactions( + original_messages, + channel.json_body["chunk"], + True, + reason="this dude sucks", + ) + + # user re-joins after kick + channel = self.make_signed_federation_request( + "GET", + f"/_matrix/federation/v1/make_join/{self.room_id}/{bad_user}?ver=10", + ) + self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) + join_result = channel.json_body + + join_event_dict = join_result["event"] + self.add_hashes_and_signatures_from_other_server( + join_event_dict, + RoomVersions.V10, + ) + channel = self.make_signed_federation_request( + "PUT", + f"/_matrix/federation/v2/send_join/{self.room_id}/x", + content=join_event_dict, + ) + self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) + + # the room should show that the bad user is a member again + new_state = self.get_success( + self._storage_controllers.state.get_current_state(self.room_id) + ) + self.assertEqual(new_state[("m.room.member", bad_user)].membership, "join") + + new_state = self.get_success( + self._storage_controllers.state.get_current_state(self.room_id) + ) + auth_ids = [ + new_state[("m.room.create", "")].event_id, + new_state[("m.room.power_levels", "")].event_id, + new_state[("m.room.member", "@remote_bad_user:other.example.com")].event_id, + ] + + # messages after kick and re-join proceed unredacted + new_original_messages = [] + for i in range(5): + remote_message = make_event_from_dict( + self.add_hashes_and_signatures_from_other_server( + { + "room_id": self.room_id, + "sender": bad_user, + "depth": 1000, + "origin_server_ts": 1, + "type": "m.room.message", + "content": {"body": f"no longer a bummer {i}"}, + "auth_events": auth_ids, + "prev_events": auth_ids, + } + ), + room_version=RoomVersions.V10, + ) + + self.get_success( + self.federation_event_handler.on_receive_pdu( + self.OTHER_SERVER_NAME, remote_message + ) + ) + new_original_messages.append(remote_message) + + filter = json.dumps({"types": [EventTypes.Message]}) + channel = self.make_request( + "GET", + f"rooms/{self.room_id}/messages?filter={filter}&limit=50", + access_token=self.creator_tok, + ) + self.assertEqual(channel.code, 200) + self._check_redactions(new_original_messages, channel.json_body["chunk"], False) + + def test_redaction_flag_ignored_for_user_if_kicker_lacks_redaction_power( + self, + ) -> None: + # change power levels so creator can kick but not redact + self.helper.send_state( + self.room_id, + "m.room.power_levels", + {"events_default": 0, "redact": 100, "users": {self.creator: 75}}, + tok=self.creator_tok, + ) + self.helper.join(self.room_id, self.bad_user_id, tok=self.bad_tok) + + # bad user sends some messages + original_ids = [] + for i in range(15): + event = {"body": f"being a menace {i}", "msgtype": "m.text"} + res = self.helper.send_event( + self.room_id, "m.room.message", event, tok=self.bad_tok, expect_code=200 + ) + original_ids.append(res["event_id"]) + + # grab original events before ban + originals = [self.get_success(self.store.get_event(x)) for x in original_ids] + + # creator kicks bad user with redaction flag + content = { + "reason": "flooding", + "org.matrix.msc4293.redact_events": True, + } + self.helper.change_membership( + self.room_id, + self.creator, + self.bad_user_id, + "kick", + content, + self.creator_tok, + ) + + filter = json.dumps({"types": [EventTypes.Message]}) + channel = self.make_request( + "GET", + f"rooms/{self.room_id}/messages?filter={filter}&limit=50", + access_token=self.creator_tok, + ) + self.assertEqual(channel.code, 200) + # messages are not redacted + self._check_redactions(originals, channel.json_body["chunk"], False) + + def test_MSC4293_flag_ignored_in_other_membership_events(self) -> None: + self.helper.join(self.room_id, self.bad_user_id, tok=self.bad_tok) + + # bad user sends some messages + original_ids = [] + for i in range(15): + event = {"body": f"being a menace {i}", "msgtype": "m.text"} + res = self.helper.send_event( + self.room_id, "m.room.message", event, tok=self.bad_tok, expect_code=200 + ) + original_ids.append(res["event_id"]) + + # grab original events before ban + originals = [self.get_success(self.store.get_event(x)) for x in original_ids] + + # bad user leaves on their own with flag + content = { + "org.matrix.msc4293.redact_events": True, + } + self.helper.change_membership( + self.room_id, + self.bad_user_id, + self.bad_user_id, + "leave", + content, + self.bad_tok, + ) + + # their messages are not redacted + filter = json.dumps({"types": [EventTypes.Message]}) + channel = self.make_request( + "GET", + f"rooms/{self.room_id}/messages?filter={filter}&limit=50", + access_token=self.creator_tok, + ) + self.assertEqual(channel.code, 200) + self._check_redactions(originals, channel.json_body["chunk"], False) + + # bad user is invited with flag in invite event + content = { + "org.matrix.msc4293.redact_events": True, + } + self.helper.change_membership( + self.room_id, + self.creator, + self.bad_user_id, + "invite", + content, + self.creator_tok, + ) + + # their messages are still not redacted + filter = json.dumps({"types": [EventTypes.Message]}) + channel = self.make_request( + "GET", + f"rooms/{self.room_id}/messages?filter={filter}&limit=50", + access_token=self.creator_tok, + ) + self.assertEqual(channel.code, 200) + self._check_redactions(originals, channel.json_body["chunk"], False) + + # bad user joins with flag in invite event + content = { + "org.matrix.msc4293.redact_events": True, + } + self.helper.change_membership( + self.room_id, + self.bad_user_id, + self.bad_user_id, + "join", + content, + self.bad_tok, + ) + + # and still their messages are not redacted + filter = json.dumps({"types": [EventTypes.Message]}) + channel = self.make_request( + "GET", + f"rooms/{self.room_id}/messages?filter={filter}&limit=50", + access_token=self.creator_tok, + ) + self.assertEqual(channel.code, 200) + self._check_redactions(originals, channel.json_body["chunk"], False) + + def test_MSC4293_redaction_applied_via_kick_api(self) -> None: + """ + Test that MSC4239 field passed through and applied when using /kick + """ + self.helper.join(self.room_id, self.bad_user_id, tok=self.bad_tok) + + # bad user sends some messages + original_ids = [] + for i in range(15): + event = {"body": f"being a menace {i}", "msgtype": "m.text"} + res = self.helper.send_event( + self.room_id, "m.room.message", event, tok=self.bad_tok, expect_code=200 + ) + original_ids.append(res["event_id"]) + + # grab original events before kick + originals = [self.get_success(self.store.get_event(x)) for x in original_ids] + + channel = self.make_request( + "POST", + f"/_matrix/client/v3/rooms/{self.room_id}/kick", + access_token=self.creator_tok, + content={ + "reason": "being annoying", + "org.matrix.msc4293.redact_events": True, + "user_id": self.bad_user_id, + }, + shorthand=False, + ) + self.assertEqual(channel.code, 200) + + filter = json.dumps({"types": [EventTypes.Message]}) + channel = self.make_request( + "GET", + f"rooms/{self.room_id}/messages?filter={filter}&limit=50", + access_token=self.creator_tok, + ) + self.assertEqual(channel.code, 200) + self._check_redactions( + originals, + channel.json_body["chunk"], + expect_redaction=True, + reason="being annoying", + ) + + def test_MSC4293_redaction_applied_via_ban_api(self) -> None: + """ + Test that MSC4239 field passed through and applied when using /ban + """ + self.helper.join(self.room_id, self.bad_user_id, tok=self.bad_tok) + + # bad user sends some messages + original_ids = [] + for i in range(15): + event = {"body": f"being a menace {i}", "msgtype": "m.text"} + res = self.helper.send_event( + self.room_id, "m.room.message", event, tok=self.bad_tok, expect_code=200 + ) + original_ids.append(res["event_id"]) + + # grab original events before ban + originals = [self.get_success(self.store.get_event(x)) for x in original_ids] + + channel = self.make_request( + "POST", + f"/_matrix/client/v3/rooms/{self.room_id}/ban", + access_token=self.creator_tok, + content={ + "reason": "being disruptive", + "org.matrix.msc4293.redact_events": True, + "user_id": self.bad_user_id, + }, + shorthand=False, + ) + self.assertEqual(channel.code, 200) + + filter = json.dumps({"types": [EventTypes.Message]}) + channel = self.make_request( + "GET", + f"rooms/{self.room_id}/messages?filter={filter}&limit=50", + access_token=self.creator_tok, + ) + self.assertEqual(channel.code, 200) + self._check_redactions( + originals, + channel.json_body["chunk"], + expect_redaction=True, + reason="being disruptive", + )