Compare commits

...

32 Commits

Author SHA1 Message Date
Travis Ralston
c4cdded23c Use generated config 2025-06-19 11:54:16 -06:00
Travis Ralston
7d46b07fb2 move config 2025-06-19 11:26:59 -06:00
Travis Ralston
746c506ad8 Add second index; move to new delta 2025-06-19 10:34:38 -06:00
Travis Ralston
cf0353551e add docs 2025-06-19 10:33:28 -06:00
Travis Ralston
592a32dee0 Merge branch 'develop' into travis/report-user 2025-06-19 10:27:02 -06:00
Travis Ralston
223df14748 Add types 2025-05-02 11:54:08 -06:00
Travis Ralston
ba32d12528 I guess imports are important 2025-05-02 11:49:32 -06:00
Travis Ralston
51aceab878 move delta again 2025-05-02 11:49:18 -06:00
Travis Ralston
ff53217d87 Empty commit to kick CI 2025-05-02 11:47:04 -06:00
turt2live
9251b45671 Attempt to fix linting 2025-05-02 17:46:50 +00:00
Travis Ralston
2f8958c126 Limit length of reason; add rate limit; move to handler 2025-05-02 11:42:43 -06:00
Travis Ralston
c394ad1290 Fix local user check 2025-05-02 11:16:13 -06:00
Travis Ralston
2058b2bd0c Merge branch 'develop' into travis/report-user 2025-05-02 11:13:20 -06:00
Travis Ralston
386a9e6faa Apply suggestions from code review
Co-authored-by: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com>
2025-05-02 11:13:01 -06:00
Travis Ralston
42b4207134 Empty commit to fix CI 2025-03-18 15:12:49 -06:00
turt2live
6ce8cc06c6 Attempt to fix linting 2025-03-18 21:12:37 +00:00
Travis Ralston
1029a79ee6 Unstable -> Stable 2025-03-18 15:10:08 -06:00
Travis Ralston
79c2a0ac8d Move delta 2025-03-18 15:07:31 -06:00
Travis Ralston
8b84c23d3a Merge branch 'develop' into travis/report-user 2025-03-18 15:06:20 -06:00
Travis Ralston
dbe43a1283 Merge branch 'develop' into travis/report-user 2025-02-13 13:20:56 -07:00
Travis Ralston
6ef7a87dc1 kick ci 2025-01-31 12:04:03 -07:00
turt2live
133380ff6d Attempt to fix linting 2025-01-31 19:03:54 +00:00
Travis Ralston
41d185c71c Adjust testing 2025-01-31 12:02:12 -07:00
Travis Ralston
44dbcab047 kick ci 2025-01-31 11:29:14 -07:00
turt2live
a842c66ecb Attempt to fix linting 2025-01-31 00:32:51 +00:00
Travis Ralston
e8d102d8c5 await 2025-01-30 17:30:57 -07:00
Travis Ralston
85747b70a7 Annotate the tests instead 2025-01-30 17:13:56 -07:00
Travis Ralston
facf07a568 Include in /versions 2025-01-30 17:13:45 -07:00
Travis Ralston
61f2750c9b kick ci 2025-01-30 16:16:25 -07:00
turt2live
f0dcc7a802 Attempt to fix linting 2025-01-30 23:13:19 +00:00
Travis Ralston
b5f359aa0c changelog 2025-01-30 16:08:11 -07:00
Travis Ralston
f347547d32 Add report user API from MSC4260 2025-01-30 16:05:31 -07:00
10 changed files with 335 additions and 0 deletions

View File

@@ -0,0 +1 @@
Add support for the [MSC4260 user report API](https://github.com/matrix-org/matrix-spec-proposals/pull/4260).

View File

@@ -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.

View File

@@ -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: >-

View File

@@ -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},
)

View File

@@ -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:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
#
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(),
)

View File

@@ -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<target_user_id>[^/]*)/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)

View File

@@ -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)

View File

@@ -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.

View File

@@ -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:
-- <https://www.gnu.org/licenses/agpl-3.0.html>.
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

View File

@@ -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"])