mirror of
https://github.com/element-hq/synapse.git
synced 2025-12-05 01:10:13 +00:00
Compare commits
32 Commits
quenting/l
...
travis/rep
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4cdded23c | ||
|
|
7d46b07fb2 | ||
|
|
746c506ad8 | ||
|
|
cf0353551e | ||
|
|
592a32dee0 | ||
|
|
223df14748 | ||
|
|
ba32d12528 | ||
|
|
51aceab878 | ||
|
|
ff53217d87 | ||
|
|
9251b45671 | ||
|
|
2f8958c126 | ||
|
|
c394ad1290 | ||
|
|
2058b2bd0c | ||
|
|
386a9e6faa | ||
|
|
42b4207134 | ||
|
|
6ce8cc06c6 | ||
|
|
1029a79ee6 | ||
|
|
79c2a0ac8d | ||
|
|
8b84c23d3a | ||
|
|
dbe43a1283 | ||
|
|
6ef7a87dc1 | ||
|
|
133380ff6d | ||
|
|
41d185c71c | ||
|
|
44dbcab047 | ||
|
|
a842c66ecb | ||
|
|
e8d102d8c5 | ||
|
|
85747b70a7 | ||
|
|
facf07a568 | ||
|
|
61f2750c9b | ||
|
|
f0dcc7a802 | ||
|
|
b5f359aa0c | ||
|
|
f347547d32 |
1
changelog.d/18120.feature
Normal file
1
changelog.d/18120.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add support for the [MSC4260 user report API](https://github.com/matrix-org/matrix-spec-proposals/pull/4260).
|
||||
@@ -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.
|
||||
|
||||
@@ -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: >-
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
98
synapse/handlers/reports.py
Normal file
98
synapse/handlers/reports.py
Normal 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(),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
22
synapse/storage/schema/main/delta/92/07_add_user_reports.sql
Normal file
22
synapse/storage/schema/main/delta/92/07_add_user_reports.sql
Normal 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
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user