From 434e38941a6b169c1905cb10ff9bd6907c765209 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 26 Jun 2025 13:27:21 +0200 Subject: [PATCH] Add `federated_user_may_invite` spam checker callback (#18241) Co-authored-by: Sebastian Spaeth Co-authored-by: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com> --- changelog.d/18241.feature | 1 + docs/modules/spam_checker_callbacks.md | 30 +++++++++ synapse/handlers/federation.py | 4 +- synapse/module_api/__init__.py | 3 + .../callbacks/spamchecker_callbacks.py | 63 +++++++++++++++++++ 5 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 changelog.d/18241.feature diff --git a/changelog.d/18241.feature b/changelog.d/18241.feature new file mode 100644 index 0000000000..01760c9d67 --- /dev/null +++ b/changelog.d/18241.feature @@ -0,0 +1 @@ +Add `federated_user_may_invite` spam checker callback which receives the entire invite event. Contributed by @tulir @ Beeper. diff --git a/docs/modules/spam_checker_callbacks.md b/docs/modules/spam_checker_callbacks.md index 39d7cbc000..49b7e06bb3 100644 --- a/docs/modules/spam_checker_callbacks.md +++ b/docs/modules/spam_checker_callbacks.md @@ -80,6 +80,8 @@ Called when processing an invitation, both when one is created locally or when receiving an invite over federation. Both inviter and invitee are represented by their Matrix user ID (e.g. `@alice:example.com`). +Note that federated invites will call `federated_user_may_invite` before this callback. + The callback must return one of: - `synapse.module_api.NOT_SPAM`, to allow the operation. Other callbacks may still @@ -97,6 +99,34 @@ be used. If this happens, Synapse will not call any of the subsequent implementa this callback. +### `federated_user_may_invite` + +_First introduced in Synapse v1.133.0_ + +```python +async def federated_user_may_invite(event: "synapse.events.EventBase") -> Union["synapse.module_api.NOT_SPAM", "synapse.module_api.errors.Codes", bool] +``` + +Called when processing an invitation received over federation. Unlike `user_may_invite`, +this callback receives the entire event, including any stripped state in the `unsigned` +section, not just the room and user IDs. + +The callback must return one of: + - `synapse.module_api.NOT_SPAM`, to allow the operation. Other callbacks may still + decide to reject it. + - `synapse.module_api.errors.Codes` to reject the operation with an error code. In case + of doubt, `synapse.module_api.errors.Codes.FORBIDDEN` is a good error code. + +If multiple modules implement this callback, they will be considered in order. If a +callback returns `synapse.module_api.NOT_SPAM`, Synapse falls through to the next one. +The value of the first callback that does not return `synapse.module_api.NOT_SPAM` will +be used. If this happens, Synapse will not call any of the subsequent implementations of +this callback. + +If all of the callbacks return `synapse.module_api.NOT_SPAM`, Synapse will also fall +through to the `user_may_invite` callback before approving the invite. + + ### `user_may_send_3pid_invite` _First introduced in Synapse v1.45.0_ diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 729b676163..015fb3edca 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -1062,8 +1062,8 @@ class FederationHandler: if self.hs.config.server.block_non_admin_invites: raise SynapseError(403, "This server does not accept room invites") - spam_check = await self._spam_checker_module_callbacks.user_may_invite( - event.sender, event.state_key, event.room_id + spam_check = ( + await self._spam_checker_module_callbacks.federated_user_may_invite(event) ) if spam_check != NOT_SPAM: raise SynapseError( diff --git a/synapse/module_api/__init__.py b/synapse/module_api/__init__.py index 656fe323f3..b1a2476da8 100644 --- a/synapse/module_api/__init__.py +++ b/synapse/module_api/__init__.py @@ -104,6 +104,7 @@ from synapse.module_api.callbacks.spamchecker_callbacks import ( CHECK_MEDIA_FILE_FOR_SPAM_CALLBACK, CHECK_REGISTRATION_FOR_SPAM_CALLBACK, CHECK_USERNAME_FOR_SPAM_CALLBACK, + FEDERATED_USER_MAY_INVITE_CALLBACK, SHOULD_DROP_FEDERATED_EVENT_CALLBACK, USER_MAY_CREATE_ROOM_ALIAS_CALLBACK, USER_MAY_CREATE_ROOM_CALLBACK, @@ -315,6 +316,7 @@ class ModuleApi: ] = None, user_may_join_room: Optional[USER_MAY_JOIN_ROOM_CALLBACK] = None, user_may_invite: Optional[USER_MAY_INVITE_CALLBACK] = None, + federated_user_may_invite: Optional[FEDERATED_USER_MAY_INVITE_CALLBACK] = None, user_may_send_3pid_invite: Optional[USER_MAY_SEND_3PID_INVITE_CALLBACK] = None, user_may_create_room: Optional[USER_MAY_CREATE_ROOM_CALLBACK] = None, user_may_create_room_alias: Optional[ @@ -338,6 +340,7 @@ class ModuleApi: should_drop_federated_event=should_drop_federated_event, user_may_join_room=user_may_join_room, user_may_invite=user_may_invite, + federated_user_may_invite=federated_user_may_invite, user_may_send_3pid_invite=user_may_send_3pid_invite, user_may_create_room=user_may_create_room, user_may_create_room_alias=user_may_create_room_alias, diff --git a/synapse/module_api/callbacks/spamchecker_callbacks.py b/synapse/module_api/callbacks/spamchecker_callbacks.py index bea5c5badf..c43824f213 100644 --- a/synapse/module_api/callbacks/spamchecker_callbacks.py +++ b/synapse/module_api/callbacks/spamchecker_callbacks.py @@ -105,6 +105,22 @@ USER_MAY_INVITE_CALLBACK = Callable[ ] ], ] +FEDERATED_USER_MAY_INVITE_CALLBACK = Callable[ + ["synapse.events.EventBase"], + 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_SEND_3PID_INVITE_CALLBACK = Callable[ [str, str, str, str], Awaitable[ @@ -266,6 +282,7 @@ def load_legacy_spam_checkers(hs: "synapse.server.HomeServer") -> None: spam_checker_methods = { "check_event_for_spam", "user_may_invite", + "federated_user_may_invite", "user_may_create_room", "user_may_create_room_alias", "user_may_publish_room", @@ -347,6 +364,9 @@ class SpamCheckerModuleApiCallbacks: ] = [] self._user_may_join_room_callbacks: List[USER_MAY_JOIN_ROOM_CALLBACK] = [] self._user_may_invite_callbacks: List[USER_MAY_INVITE_CALLBACK] = [] + self._federated_user_may_invite_callbacks: List[ + FEDERATED_USER_MAY_INVITE_CALLBACK + ] = [] self._user_may_send_3pid_invite_callbacks: List[ USER_MAY_SEND_3PID_INVITE_CALLBACK ] = [] @@ -377,6 +397,7 @@ class SpamCheckerModuleApiCallbacks: ] = None, user_may_join_room: Optional[USER_MAY_JOIN_ROOM_CALLBACK] = None, user_may_invite: Optional[USER_MAY_INVITE_CALLBACK] = None, + federated_user_may_invite: Optional[FEDERATED_USER_MAY_INVITE_CALLBACK] = None, user_may_send_3pid_invite: Optional[USER_MAY_SEND_3PID_INVITE_CALLBACK] = None, user_may_create_room: Optional[USER_MAY_CREATE_ROOM_CALLBACK] = None, user_may_create_room_alias: Optional[ @@ -406,6 +427,11 @@ class SpamCheckerModuleApiCallbacks: if user_may_invite is not None: self._user_may_invite_callbacks.append(user_may_invite) + if federated_user_may_invite is not None: + self._federated_user_may_invite_callbacks.append( + federated_user_may_invite, + ) + if user_may_send_3pid_invite is not None: self._user_may_send_3pid_invite_callbacks.append( user_may_send_3pid_invite, @@ -605,6 +631,43 @@ class SpamCheckerModuleApiCallbacks: # No spam-checker has rejected the request, let it pass. return self.NOT_SPAM + async def federated_user_may_invite( + self, event: "synapse.events.EventBase" + ) -> Union[Tuple[Codes, dict], Literal["NOT_SPAM"]]: + """Checks if a given user may send an invite + + Args: + event: The event to be checked + + Returns: + NOT_SPAM if the operation is permitted, Codes otherwise. + """ + for callback in self._federated_user_may_invite_callbacks: + with Measure(self.clock, f"{callback.__module__}.{callback.__qualname__}"): + res = await delay_cancellation(callback(event)) + # Normalize return values to `Codes` or `"NOT_SPAM"`. + if res is True or res is self.NOT_SPAM: + continue + elif res is False: + return synapse.api.errors.Codes.FORBIDDEN, {} + elif isinstance(res, synapse.api.errors.Codes): + return res, {} + elif ( + isinstance(res, tuple) + and len(res) == 2 + and isinstance(res[0], synapse.api.errors.Codes) + and isinstance(res[1], dict) + ): + return res + else: + logger.warning( + "Module returned invalid value, rejecting invite as spam" + ) + return synapse.api.errors.Codes.FORBIDDEN, {} + + # Check the standard user_may_invite callback if no module has rejected the invite yet. + return await self.user_may_invite(event.sender, event.state_key, event.room_id) + async def user_may_send_3pid_invite( self, inviter_userid: str, medium: str, address: str, room_id: str ) -> Union[Tuple[Codes, dict], Literal["NOT_SPAM"]]: