summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--changelog.d/18486.feature1
-rw-r--r--docs/modules/spam_checker_callbacks.md9
-rw-r--r--docs/spam_checker.md2
-rw-r--r--synapse/handlers/room.py31
-rw-r--r--synapse/module_api/callbacks/spamchecker_callbacks.py62
-rw-r--r--tests/module_api/test_spamchecker.py155
6 files changed, 230 insertions, 30 deletions
diff --git a/changelog.d/18486.feature b/changelog.d/18486.feature
new file mode 100644

index 0000000000..7e1c713081 --- /dev/null +++ b/changelog.d/18486.feature
@@ -0,0 +1 @@ +Pass room_config argument to user_may_create_room spam checker module callback. diff --git a/docs/modules/spam_checker_callbacks.md b/docs/modules/spam_checker_callbacks.md
index 063099a127..ee9e8f3abb 100644 --- a/docs/modules/spam_checker_callbacks.md +++ b/docs/modules/spam_checker_callbacks.md
@@ -159,12 +159,19 @@ _First introduced in Synapse v1.37.0_ _Changed in Synapse v1.62.0: `synapse.module_api.NOT_SPAM` and `synapse.module_api.errors.Codes` can be returned by this callback. Returning a boolean is now deprecated._ +_Changed in Synapse v1.132.0: Added the `room_config` argument. Callbacks that only expect a single `user_id` argument are still supported._ + ```python -async def user_may_create_room(user_id: str) -> Union["synapse.module_api.NOT_SPAM", "synapse.module_api.errors.Codes", bool] +async def user_may_create_room(user_id: str, room_config: synapse.module_api.JsonDict) -> Union["synapse.module_api.NOT_SPAM", "synapse.module_api.errors.Codes", bool] ``` Called when processing a room creation request. +The arguments passed to this callback are: + +* `user_id`: The Matrix user ID of the user (e.g. `@alice:example.com`). +* `room_config`: The contents of the body of a [/createRoom request](https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3createroom) as a dictionary. + The callback must return one of: - `synapse.module_api.NOT_SPAM`, to allow the operation. Other callbacks may still decide to reject it. diff --git a/docs/spam_checker.md b/docs/spam_checker.md
index 4ace3512b3..ead0f03595 100644 --- a/docs/spam_checker.md +++ b/docs/spam_checker.md
@@ -63,7 +63,7 @@ class ExampleSpamChecker: async def user_may_invite(self, inviter_userid, invitee_userid, room_id): return True # allow all invites - async def user_may_create_room(self, userid): + async def user_may_create_room(self, userid, room_config): return True # allow all room creations async def user_may_create_room_alias(self, userid, room_alias): diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py
index 763f99e028..1ccb6f7171 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py
@@ -468,17 +468,6 @@ class RoomCreationHandler: """ user_id = requester.user.to_string() - spam_check = await self._spam_checker_module_callbacks.user_may_create_room( - user_id - ) - if spam_check != self._spam_checker_module_callbacks.NOT_SPAM: - raise SynapseError( - 403, - "You are not permitted to create rooms", - errcode=spam_check[0], - additional_fields=spam_check[1], - ) - creation_content: JsonDict = { "room_version": new_room_version.identifier, "predecessor": {"room_id": old_room_id, "event_id": tombstone_event_id}, @@ -585,6 +574,24 @@ class RoomCreationHandler: if current_power_level_int < needed_power_level: user_power_levels[user_id] = needed_power_level + # We construct what the body of a call to /createRoom would look like for passing + # to the spam checker. We don't include a preset here, as we expect the + # initial state to contain everything we need. + spam_check = await self._spam_checker_module_callbacks.user_may_create_room( + user_id, + { + "creation_content": creation_content, + "initial_state": list(initial_state.items()), + }, + ) + if spam_check != self._spam_checker_module_callbacks.NOT_SPAM: + raise SynapseError( + 403, + "You are not permitted to create rooms", + errcode=spam_check[0], + additional_fields=spam_check[1], + ) + await self._send_events_for_new_room( requester, new_room_id, @@ -786,7 +793,7 @@ class RoomCreationHandler: if not is_requester_admin: spam_check = await self._spam_checker_module_callbacks.user_may_create_room( - user_id + user_id, config ) if spam_check != self._spam_checker_module_callbacks.NOT_SPAM: raise SynapseError( diff --git a/synapse/module_api/callbacks/spamchecker_callbacks.py b/synapse/module_api/callbacks/spamchecker_callbacks.py
index a86b46ba54..9b373ff67c 100644 --- a/synapse/module_api/callbacks/spamchecker_callbacks.py +++ b/synapse/module_api/callbacks/spamchecker_callbacks.py
@@ -22,6 +22,7 @@ import functools import inspect import logging +from copy import deepcopy from typing import ( TYPE_CHECKING, Any, @@ -120,20 +121,24 @@ USER_MAY_SEND_3PID_INVITE_CALLBACK = Callable[ ] ], ] -USER_MAY_CREATE_ROOM_CALLBACK = Callable[ - [str], - Awaitable[ - Union[ - Literal["NOT_SPAM"], - Codes, - # Highly experimental, not officially part of the spamchecker API, may - # disappear without warning depending on the results of ongoing - # experiments. - # Use this to return additional information as part of an error. - Tuple[Codes, JsonDict], - # Deprecated - bool, - ] +USER_MAY_CREATE_ROOM_CALLBACK_RETURN_VALUE = Union[ + Literal["NOT_SPAM"], + Codes, + # Highly experimental, not officially part of the spamchecker API, may + # disappear without warning depending on the results of ongoing + # experiments. + # Use this to return additional information as part of an error. + Tuple[Codes, JsonDict], + # Deprecated + bool, +] +USER_MAY_CREATE_ROOM_CALLBACK = Union[ + Callable[ + [str, JsonDict], + Awaitable[USER_MAY_CREATE_ROOM_CALLBACK_RETURN_VALUE], + ], + Callable[ # Single argument variant for backwards compatibility + [str], Awaitable[USER_MAY_CREATE_ROOM_CALLBACK_RETURN_VALUE] ], ] USER_MAY_CREATE_ROOM_ALIAS_CALLBACK = Callable[ @@ -622,16 +627,41 @@ class SpamCheckerModuleApiCallbacks: return self.NOT_SPAM async def user_may_create_room( - self, userid: str + self, userid: str, room_config: JsonDict ) -> Union[Tuple[Codes, dict], Literal["NOT_SPAM"]]: """Checks if a given user may create a room Args: userid: The ID of the user attempting to create a room + room_config: The room creation configuration which is the body of the /createRoom request """ for callback in self._user_may_create_room_callbacks: with Measure(self.clock, f"{callback.__module__}.{callback.__qualname__}"): - res = await delay_cancellation(callback(userid)) + checker_args = inspect.signature(callback) + # Also ensure backwards compatibility with spam checker callbacks + # that don't expect the room_config argument. + if len(checker_args.parameters) == 2: + callback_with_requester_id = cast( + Callable[ + [str, JsonDict], + Awaitable[USER_MAY_CREATE_ROOM_CALLBACK_RETURN_VALUE], + ], + callback, + ) + # We make a copy of the config to ensure the spam checker cannot modify it. + res = await delay_cancellation( + callback_with_requester_id(userid, deepcopy(room_config)) + ) + else: + callback_without_requester_id = cast( + Callable[ + [str], Awaitable[USER_MAY_CREATE_ROOM_CALLBACK_RETURN_VALUE] + ], + callback, + ) + res = await delay_cancellation( + callback_without_requester_id(userid) + ) if res is True or res is self.NOT_SPAM: continue elif res is False: diff --git a/tests/module_api/test_spamchecker.py b/tests/module_api/test_spamchecker.py new file mode 100644
index 0000000000..82790222c8 --- /dev/null +++ b/tests/module_api/test_spamchecker.py
@@ -0,0 +1,155 @@ +# +# 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>. +# +# +from typing import Literal, Union + +from twisted.test.proto_helpers import MemoryReactor + +from synapse.config.server import DEFAULT_ROOM_VERSION +from synapse.rest import admin, login, room, room_upgrade_rest_servlet +from synapse.server import HomeServer +from synapse.types import Codes, JsonDict +from synapse.util import Clock + +from tests.server import FakeChannel +from tests.unittest import HomeserverTestCase + + +class SpamCheckerTestCase(HomeserverTestCase): + servlets = [ + room.register_servlets, + admin.register_servlets, + login.register_servlets, + room_upgrade_rest_servlet.register_servlets, + ] + + def prepare( + self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer + ) -> None: + self._module_api = homeserver.get_module_api() + self.user_id = self.register_user("user", "password") + self.token = self.login("user", "password") + + def create_room(self, content: JsonDict) -> FakeChannel: + channel = self.make_request( + "POST", + "/_matrix/client/r0/createRoom", + content, + access_token=self.token, + ) + + return channel + + def test_may_user_create_room(self) -> None: + """Test that the may_user_create_room callback is called when a user + creates a room, and that it receives the correct parameters. + """ + + async def user_may_create_room( + user_id: str, room_config: JsonDict + ) -> Union[Literal["NOT_SPAM"], Codes]: + self.last_room_config = room_config + self.last_user_id = user_id + return "NOT_SPAM" + + self._module_api.register_spam_checker_callbacks( + user_may_create_room=user_may_create_room + ) + + channel = self.create_room({"foo": "baa"}) + self.assertEqual(channel.code, 200) + self.assertEqual(self.last_user_id, self.user_id) + self.assertEqual(self.last_room_config["foo"], "baa") + + def test_may_user_create_room_on_upgrade(self) -> None: + """Test that the may_user_create_room callback is called when a room is upgraded.""" + + # First, create a room to upgrade. + channel = self.create_room({"topic": "foo"}) + self.assertEqual(channel.code, 200) + room_id = channel.json_body["room_id"] + + async def user_may_create_room( + user_id: str, room_config: JsonDict + ) -> Union[Literal["NOT_SPAM"], Codes]: + self.last_room_config = room_config + self.last_user_id = user_id + return "NOT_SPAM" + + # Register the callback for spam checking. + self._module_api.register_spam_checker_callbacks( + user_may_create_room=user_may_create_room + ) + + # Now upgrade the room. + channel = self.make_request( + "POST", + f"/_matrix/client/r0/rooms/{room_id}/upgrade", + # This will upgrade a room to the same version, but that's fine. + content={"new_version": DEFAULT_ROOM_VERSION}, + access_token=self.token, + ) + + # Check that the callback was called and the room was upgraded. + self.assertEqual(channel.code, 200) + self.assertEqual(self.last_user_id, self.user_id) + # Check that the initial state received by callback contains the topic event. + self.assertTrue( + any( + event[0][0] == "m.room.topic" and event[1].get("topic") == "foo" + for event in self.last_room_config["initial_state"] + ) + ) + + def test_may_user_create_room_disallowed(self) -> None: + """Test that the codes response from may_user_create_room callback is respected + and returned via the API. + """ + + async def user_may_create_room( + user_id: str, room_config: JsonDict + ) -> Union[Literal["NOT_SPAM"], Codes]: + self.last_room_config = room_config + self.last_user_id = user_id + return Codes.UNAUTHORIZED + + self._module_api.register_spam_checker_callbacks( + user_may_create_room=user_may_create_room + ) + + channel = self.create_room({"foo": "baa"}) + self.assertEqual(channel.code, 403) + self.assertEqual(channel.json_body["errcode"], Codes.UNAUTHORIZED) + self.assertEqual(self.last_user_id, self.user_id) + self.assertEqual(self.last_room_config["foo"], "baa") + + def test_may_user_create_room_compatibility(self) -> None: + """Test that the may_user_create_room callback is called when a user + creates a room for a module that uses the old callback signature + (without the `room_config` parameter) + """ + + async def user_may_create_room( + user_id: str, + ) -> Union[Literal["NOT_SPAM"], Codes]: + self.last_user_id = user_id + return "NOT_SPAM" + + self._module_api.register_spam_checker_callbacks( + user_may_create_room=user_may_create_room + ) + + channel = self.create_room({"foo": "baa"}) + self.assertEqual(channel.code, 200) + self.assertEqual(self.last_user_id, self.user_id)