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 259b2c70cbf..28f3d612170 100644 --- a/synapse/config/experimental.py +++ b/synapse/config/experimental.py @@ -569,3 +569,6 @@ 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) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 1e738f484f9..8c557eac1ca 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1966,6 +1966,9 @@ async def _check_for_soft_fail( Does nothing for events in rooms with partial state, since we may not have an accurate membership event for the sender in the current state. + Also checks if event should be redacted due to a MSC4293 redaction flag in kick/ban + event for user + Args: event context: The `EventContext` which we are about to persist the event with. @@ -2065,6 +2068,63 @@ async def _check_for_soft_fail( soft_failed_event_counter.inc() event.internal_metadata.soft_failed = True + if not self._config.experimental.msc4293_enabled: + return + # Use already calculated auth events to determine if the event should be redacted due to kick/ban + state_map = {} + for auth_event in current_auth_events: + state_map[(auth_event.type, auth_event.state_key)] = auth_event + + for auth_event in current_auth_events: + if auth_event.get("state_key") is None: + continue + if ( + auth_event.type != EventTypes.Member + or auth_event.state_key != event.sender + ): + continue + if auth_event.membership not in [Membership.LEAVE, Membership.BAN]: + continue + # self-bans currently are not authorized so we don't check for that + # case + if ( + auth_event.membership == Membership.LEAVE + and auth_event.sender == auth_event.state_key + ): + continue + # we have a ban or kick for this sender, + # check for redaction flag and apply if found + autoredact = auth_event.content.get( + "org.matrix.msc4293.redact_events", False + ) + if not autoredact or not isinstance(autoredact, bool): + continue + + # check that the sender of the kick/ban can redact + room_version_obj = KNOWN_ROOM_VERSIONS[room_version] + if event_auth.check_redaction(room_version_obj, event, state_map): + # copy through the redacting event + event.unsigned["redacted_because"] = auth_event.get_dict() + await self._store.db_pool.simple_upsert( + table="redactions", + keyvalues={ + "event_id": auth_event.event_id, + "redacts": event.event_id, + }, + values={"received_ts": self._clock.time_msec()}, + insertion_values={ + "event_id": auth_event.event_id, + "redacts": event.event_id, + "received_ts": self._clock.time_msec(), + }, + ) + await self._store.db_pool.runInteraction( + "invalidate cache", + self._store.invalidate_get_event_cache_after_txn, + event.event_id, + ) + break + async def _load_or_fetch_auth_events_for_event( self, destination: Optional[str], event: EventBase ) -> Collection[EventBase]: diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py index c64ce1f9c79..9c41998cef3 100644 --- a/synapse/rest/client/room.py +++ b/synapse/rest/client/room.py @@ -1088,6 +1088,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] @@ -1111,12 +1112,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", @@ -1127,12 +1128,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. @@ -1141,12 +1142,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( @@ -1155,7 +1163,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 b7cc0433e75..b45648ad411 100644 --- a/synapse/storage/databases/main/events.py +++ b/synapse/storage/databases/main/events.py @@ -108,6 +108,9 @@ (EventTypes.Tombstone, ""), ) +# An arbitrarily large number +MAX_EVENTS = 1000000 + @attr.s(slots=True, auto_attribs=True) class DeltaState: @@ -376,6 +379,85 @@ async def _persist_events_and_state_updates( event_counter.labels(event.type, origin_type, origin_entity).inc() + # check for msc4293 redact_events flag and apply if found + if ( + not self.hs.config.experimental.msc4293_enabled + or event.type != EventTypes.Member + ): + continue + 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.LEAVE + and event.sender == event.state_key + ): + continue + # check that sender can redact + state_filter = StateFilter.from_types([(EventTypes.PowerLevels, "")]) + 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) + if pl_event: + 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 not redact_level: + 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: + if sender_level < room_redaction_level: + continue + + if sender_level > redact_level: + ids_to_redact = ( + await self.store.get_events_sent_by_user_in_room( + event.state_key, event.room_id, limit=MAX_EVENTS + ) + ) + if not ids_to_redact: + continue + + key_values = [(event.event_id, x) for x in ids_to_redact] + value_values = [ + (self._clock.time_msec(),) for x in ids_to_redact + ] + await self.db_pool.simple_upsert_many( + table="redactions", + key_names=["event_id", "redacts"], + key_values=key_values, + value_names=["received_ts"], + value_values=value_values, + desc="redact_on_ban_redaction_txn", + ) + + redacted_events = await self.store.get_events_as_list( + ids_to_redact + ) + for redacted_event in redacted_events: + redacted_event.unsigned["redacted_because"] = event + + # 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 + for id in ids_to_redact: + await self.db_pool.runInteraction( + "invalidate cache", + self.store.invalidate_get_event_cache_after_txn, + id, + ) + if new_forward_extremities: self.store.get_latest_event_ids_in_room.prefill( (room_id,), frozenset(new_forward_extremities) @@ -2768,9 +2850,8 @@ def _store_redaction(self, txn: LoggingTransaction, event: EventBase) -> None: self.db_pool.simple_upsert_txn( txn, table="redactions", - keyvalues={"event_id": event.event_id}, + keyvalues={"event_id": event.event_id, "redacts": event.redacts}, values={ - "redacts": event.redacts, "received_ts": self._clock.time_msec(), }, ) diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index 58451d3ff19..5f6610b1bb7 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -1962,6 +1962,28 @@ def __init__( ): super().__init__(database, db_conn, hs) + self.db_pool.updates.register_background_index_update( + "redactions_add_event_id_idx", + "redactions_event_id", + "redactions", + ["event_id"], + ) + + self.db_pool.updates.register_background_index_update( + "redactions_add_redacts_idx", + "redactions_redacts", + "redactions", + ["redacts"], + ) + + self.db_pool.updates.register_background_index_update( + "redactions_add_have_censored_ts", + "redactions_have_censored_ts", + "redactions", + ["received_ts"], + where_clause="NOT have_censored", + ) + self.db_pool.updates.register_background_update_handler( "insert_room_retention", self._background_insert_retention, diff --git a/synapse/storage/schema/main/delta/92/06_redactions_multitarget.py b/synapse/storage/schema/main/delta/92/06_redactions_multitarget.py new file mode 100644 index 00000000000..e31a4f693e9 --- /dev/null +++ b/synapse/storage/schema/main/delta/92/06_redactions_multitarget.py @@ -0,0 +1,72 @@ +from synapse.storage.database import LoggingTransaction +from synapse.storage.engines import BaseDatabaseEngine, PostgresEngine + + +def run_create( + cur: LoggingTransaction, + database_engine: BaseDatabaseEngine, +) -> None: + """ + Drop unique constraint event_id and add unique constraint (event_id, redacts) + """ + if isinstance(database_engine, PostgresEngine): + add_constraint_sql = """ + ALTER TABLE ONLY redactions ADD CONSTRAINT redactions_event_id_redacts_key UNIQUE (event_id, redacts); + """ + cur.execute(add_constraint_sql) + + drop_constraint_sql = """ + ALTER TABLE ONLY redactions DROP CONSTRAINT redactions_event_id_key; + """ + cur.execute(drop_constraint_sql) + + else: + # in SQLite we need to rewrite the table to change the constraint. + # First drop any temporary table that might be here from a previous failed migration. + cur.execute("DROP TABLE IF EXISTS temp_redactions") + + create_sql = """ + CREATE TABLE temp_redactions ( + event_id TEXT NOT NULL, + redacts TEXT NOT NULL, + have_censored BOOL NOT NULL DEFAULT false, + received_ts BIGINT, + UNIQUE(event_id, redacts) + ); + """ + cur.execute(create_sql) + + copy_sql = """ + INSERT INTO temp_redactions ( + event_id, + redacts, + have_censored, + received_ts + ) SELECT r.event_id, r.redacts, r.have_censored, r.received_ts FROM redactions AS r; + """ + cur.execute(copy_sql) + + drop_sql = """ + DROP TABLE redactions + """ + cur.execute(drop_sql) + + rename_sql = """ + ALTER TABLE temp_redactions RENAME to redactions + """ + cur.execute(rename_sql) + + sqlite3_idx_update_sql = """ + INSERT INTO background_updates (ordering, update_name, progress_json) \ + VALUES (?, ?, ?); + """ + cur.execute(sqlite3_idx_update_sql, (9206, "redactions_add_redacts_idx", "{}")) + cur.execute( + sqlite3_idx_update_sql, (9206, "redactions_add_have_censored_ts", "{}") + ) + + # in either case the event_id index needs to be re-created + idx_sql = """ + INSERT INTO background_updates (ordering, update_name, progress_json) VALUES (?, ?, ?); + """ + cur.execute(idx_sql, (9206, "redactions_add_event_id_idx", "{}")) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 6c93ead3b89..eff06c16339 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 ( @@ -4401,3 +4402,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 send 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", content, 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 send 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 send 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 send 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 send 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 send 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 send 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", + )