diff --git a/changelog.d/18120.feature b/changelog.d/18120.feature new file mode 100644 index 0000000000..15cfabba42 --- /dev/null +++ b/changelog.d/18120.feature @@ -0,0 +1 @@ +Add support for the [MSC4260 user report API](https://github.com/matrix-org/matrix-spec-proposals/pull/4260). \ No newline at end of file diff --git a/docs/usage/configuration/config_documentation.md b/docs/usage/configuration/config_documentation.md index 860837cae5..257ea4a1a2 100644 --- a/docs/usage/configuration/config_documentation.md +++ b/docs/usage/configuration/config_documentation.md @@ -1937,6 +1937,33 @@ rc_delayed_event_mgmt: burst_count: 20.0 ``` --- +### `rc_reports` + +*(object)* Ratelimiting settings for reporting content. +This is a ratelimiting option that ratelimits reports made by users about content they see. +Setting this to a high value allows users to report content quickly, possibly in duplicate. This can result in higher database usage. + +This setting has the following sub-options: + +* `per_second` (number): Maximum number of requests a client can send per second. + +* `burst_count` (number): Maximum number of requests a client can send before being throttled. + +Default configuration: +```yaml +rc_reports: + per_user: + per_second: 1.0 + burst_count: 5.0 +``` + +Example configuration: +```yaml +rc_reports: + per_second: 2.0 + burst_count: 20.0 +``` +--- ### `federation_rr_transactions_per_room_per_second` *(integer)* Sets outgoing federation transaction frequency for sending read-receipts, per-room. diff --git a/schema/synapse-config.schema.yaml b/schema/synapse-config.schema.yaml index 52a6d5cf71..8a5c685049 100644 --- a/schema/synapse-config.schema.yaml +++ b/schema/synapse-config.schema.yaml @@ -2185,6 +2185,23 @@ properties: examples: - per_second: 2.0 burst_count: 20.0 + rc_reports: + $ref: "#/$defs/rc" + description: >- + Ratelimiting settings for reporting content. + + This is a ratelimiting option that ratelimits reports made by users + about content they see. + + Setting this to a high value allows users to report content quickly, possibly in + duplicate. This can result in higher database usage. + default: + per_user: + per_second: 1.0 + burst_count: 5.0 + examples: + - per_second: 2.0 + burst_count: 20.0 federation_rr_transactions_per_room_per_second: type: integer description: >- diff --git a/synapse/config/ratelimiting.py b/synapse/config/ratelimiting.py index eb1dc2dacb..290701615f 100644 --- a/synapse/config/ratelimiting.py +++ b/synapse/config/ratelimiting.py @@ -240,3 +240,9 @@ class RatelimitConfig(Config): "rc_delayed_event_mgmt", defaults={"per_second": 1, "burst_count": 5}, ) + + self.rc_reports = RatelimitSettings.parse( + config, + "rc_reports", + defaults={"per_second": 1, "burst_count": 5}, + ) diff --git a/synapse/handlers/reports.py b/synapse/handlers/reports.py new file mode 100644 index 0000000000..a7b8a4bed7 --- /dev/null +++ b/synapse/handlers/reports.py @@ -0,0 +1,98 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright 2015, 2016 OpenMarket Ltd +# Copyright (C) 2023 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: +# . +# +# +import logging +from http import HTTPStatus +from typing import TYPE_CHECKING + +from synapse.api.errors import Codes, SynapseError +from synapse.api.ratelimiting import Ratelimiter +from synapse.types import ( + Requester, +) + +if TYPE_CHECKING: + from synapse.server import HomeServer + +logger = logging.getLogger(__name__) + + +class ReportsHandler: + def __init__(self, hs: "HomeServer"): + self._hs = hs + self._store = hs.get_datastores().main + self._clock = hs.get_clock() + + # Ratelimiter for management of existing delayed events, + # keyed by the requesting user ID. + self._reports_ratelimiter = Ratelimiter( + store=self._store, + clock=self._clock, + cfg=hs.config.ratelimiting.rc_reports, + ) + + async def report_user( + self, requester: Requester, target_user_id: str, reason: str + ) -> None: + """Files a report against a user from a user. + + Rate and size limits are applied to the report. If the user being reported + does not belong to this server, the report is ignored. This check is done + after the limits to reduce DoS potential. + + If the user being reported belongs to this server, but doesn't exist, we + similarly ignore the report. The spec allows us to return an error if we + want to, but we choose to hide that user's existence instead. + + If the report is otherwise valid (for a user which exists on our server), + we append it to the database for later processing. + + Args: + requester - The user filing the report. + target_user_id - The user being reported. + reason - The user-supplied reason the user is being reported. + + Raises: + SynapseError for BAD_REQUEST/BAD_JSON if the reason is too long. + """ + + await self._check_limits(requester) + + if len(reason) > 1000: + raise SynapseError( + HTTPStatus.BAD_REQUEST, + "Reason must be less than 1000 characters", + Codes.BAD_JSON, + ) + + if not self._hs.is_mine_id(target_user_id): + return # hide that they're not ours/that we can't do anything about them + + user = await self._store.get_user_by_id(target_user_id) + if user is None: + return # hide that they don't exist + + await self._store.add_user_report( + target_user_id=target_user_id, + user_id=requester.user.to_string(), + reason=reason, + received_ts=self._clock.time_msec(), + ) + + async def _check_limits(self, requester: Requester) -> None: + await self._reports_ratelimiter.ratelimit( + requester, + requester.user.to_string(), + ) diff --git a/synapse/rest/client/reporting.py b/synapse/rest/client/reporting.py index c5037be8b7..484827d9f2 100644 --- a/synapse/rest/client/reporting.py +++ b/synapse/rest/client/reporting.py @@ -150,6 +150,44 @@ class ReportRoomRestServlet(RestServlet): return 200, {} +class ReportUserRestServlet(RestServlet): + """This endpoint lets clients report a user for abuse. + + Introduced by MSC4260: https://github.com/matrix-org/matrix-spec-proposals/pull/4260 + """ + + PATTERNS = list( + client_patterns( + "/users/(?P[^/]*)/report$", + releases=("v3",), + unstable=False, + v1=False, + ) + ) + + def __init__(self, hs: "HomeServer"): + super().__init__() + self.hs = hs + self.auth = hs.get_auth() + self.clock = hs.get_clock() + self.store = hs.get_datastores().main + self.handler = hs.get_reports_handler() + + class PostBody(RequestBodyModel): + reason: StrictStr + + async def on_POST( + self, request: SynapseRequest, target_user_id: str + ) -> Tuple[int, JsonDict]: + requester = await self.auth.get_user_by_req(request) + body = parse_and_validate_json_object_from_request(request, self.PostBody) + + await self.handler.report_user(requester, target_user_id, body.reason) + + return 200, {} + + def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: ReportEventRestServlet(hs).register(http_server) ReportRoomRestServlet(hs).register(http_server) + ReportUserRestServlet(hs).register(http_server) diff --git a/synapse/server.py b/synapse/server.py index 2add4d4e6e..fd16abb9ea 100644 --- a/synapse/server.py +++ b/synapse/server.py @@ -94,6 +94,7 @@ from synapse.handlers.read_marker import ReadMarkerHandler from synapse.handlers.receipts import ReceiptsHandler from synapse.handlers.register import RegistrationHandler from synapse.handlers.relations import RelationsHandler +from synapse.handlers.reports import ReportsHandler from synapse.handlers.room import ( RoomContextHandler, RoomCreationHandler, @@ -718,6 +719,10 @@ class HomeServer(metaclass=abc.ABCMeta): def get_receipts_handler(self) -> ReceiptsHandler: return ReceiptsHandler(self) + @cache_in_self + def get_reports_handler(self) -> ReportsHandler: + return ReportsHandler(self) + @cache_in_self def get_read_marker_handler(self) -> ReadMarkerHandler: return ReadMarkerHandler(self) diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index 1df06a5171..58451d3ff1 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -2421,6 +2421,7 @@ class RoomStore(RoomBackgroundUpdateStore, RoomWorkerStore): self._event_reports_id_gen = IdGenerator(db_conn, "event_reports", "id") self._room_reports_id_gen = IdGenerator(db_conn, "room_reports", "id") + self._user_reports_id_gen = IdGenerator(db_conn, "user_reports", "id") self._instance_name = hs.get_instance_name() @@ -2662,6 +2663,37 @@ class RoomStore(RoomBackgroundUpdateStore, RoomWorkerStore): ) return next_id + async def add_user_report( + self, + target_user_id: str, + user_id: str, + reason: str, + received_ts: int, + ) -> int: + """Add a user report + + Args: + target_user_id: The user ID being reported. + user_id: User who reported the user. + reason: Description that the user specifies. + received_ts: Time when the user submitted the report (milliseconds). + Returns: + ID of the room report. + """ + next_id = self._user_reports_id_gen.get_next() + await self.db_pool.simple_insert( + table="user_reports", + values={ + "id": next_id, + "received_ts": received_ts, + "target_user_id": target_user_id, + "user_id": user_id, + "reason": reason, + }, + desc="add_user_report", + ) + return next_id + async def clear_partial_state_room(self, room_id: str) -> Optional[int]: """Clears the partial state flag for a room. diff --git a/synapse/storage/schema/main/delta/92/07_add_user_reports.sql b/synapse/storage/schema/main/delta/92/07_add_user_reports.sql new file mode 100644 index 0000000000..7439dad6d6 --- /dev/null +++ b/synapse/storage/schema/main/delta/92/07_add_user_reports.sql @@ -0,0 +1,22 @@ +-- +-- 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 user_reports ( + id BIGINT NOT NULL PRIMARY KEY, + received_ts BIGINT NOT NULL, + target_user_id TEXT NOT NULL, + user_id TEXT NOT NULL, + reason TEXT NOT NULL +); +CREATE INDEX user_reports_target_user_id ON user_reports(target_user_id); -- for lookups +CREATE INDEX user_reports_user_id ON user_reports(user_id); -- for lookups diff --git a/tests/rest/client/test_reporting.py b/tests/rest/client/test_reporting.py index 723553979f..9335d46eb5 100644 --- a/tests/rest/client/test_reporting.py +++ b/tests/rest/client/test_reporting.py @@ -18,6 +18,7 @@ # [This file includes modifications made by New Vector Limited] # # +from typing import Optional from twisted.test.proto_helpers import MemoryReactor @@ -201,3 +202,91 @@ class ReportRoomTestCase(unittest.HomeserverTestCase): shorthand=False, ) self.assertEqual(response_status, channel.code, msg=channel.result["body"]) + + +class ReportUserTestCase(unittest.HomeserverTestCase): + servlets = [ + synapse.rest.admin.register_servlets, + login.register_servlets, + room.register_servlets, + reporting.register_servlets, + ] + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + self.other_user = self.register_user("user", "pass") + self.other_user_tok = self.login("user", "pass") + + self.target_user_id = self.register_user("target_user", "pass") + + def test_reason_str(self) -> None: + data = {"reason": "this makes me sad"} + self._assert_status(200, data) + + rows = self.get_success( + self.hs.get_datastores().main.db_pool.simple_select_onecol( + table="user_reports", + keyvalues={"target_user_id": self.target_user_id}, + retcol="id", + desc="get_user_report_ids", + ) + ) + self.assertEqual(len(rows), 1) + + def test_no_reason(self) -> None: + data = {"not_reason": "for typechecking"} + self._assert_status(400, data) + + def test_reason_nonstring(self) -> None: + data = {"reason": 42} + self._assert_status(400, data) + + def test_reason_null(self) -> None: + data = {"reason": None} + self._assert_status(400, data) + + def test_reason_long(self) -> None: + data = {"reason": "x" * 1001} + self._assert_status(400, data) + + def test_cannot_report_nonlocal_user(self) -> None: + """ + Tests that we ignore reports for nonlocal users. + """ + target_user_id = "@bloop:example.org" + data = {"reason": "i am very sad"} + self._assert_status(200, data, target_user_id) + self._assert_no_reports_for_user(target_user_id) + + def test_can_report_nonexistent_user(self) -> None: + """ + Tests that we ignore reports for nonexistent users. + """ + target_user_id = f"@bloop:{self.hs.hostname}" + data = {"reason": "i am very sad"} + self._assert_status(200, data, target_user_id) + self._assert_no_reports_for_user(target_user_id) + + def _assert_no_reports_for_user(self, target_user_id: str) -> None: + rows = self.get_success( + self.hs.get_datastores().main.db_pool.simple_select_onecol( + table="user_reports", + keyvalues={"target_user_id": target_user_id}, + retcol="id", + desc="get_user_report_ids", + ) + ) + self.assertEqual(len(rows), 0) + + def _assert_status( + self, response_status: int, data: JsonDict, user_id: Optional[str] = None + ) -> None: + if user_id is None: + user_id = self.target_user_id + channel = self.make_request( + "POST", + f"/_matrix/client/v3/users/{user_id}/report", + data, + access_token=self.other_user_tok, + shorthand=False, + ) + self.assertEqual(response_status, channel.code, msg=channel.result["body"])