Add federated_user_may_invite spam checker callback (#18241)

Co-authored-by: Sebastian Spaeth <Sebastian@SSpaeth.de>
Co-authored-by: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com>
This commit is contained in:
Tulir Asokan
2025-06-26 13:27:21 +02:00
committed by GitHub
parent b1396475c4
commit 434e38941a
5 changed files with 99 additions and 2 deletions

View File

@@ -0,0 +1 @@
Add `federated_user_may_invite` spam checker callback which receives the entire invite event. Contributed by @tulir @ Beeper.

View File

@@ -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 receiving an invite over federation. Both inviter and invitee are represented by
their Matrix user ID (e.g. `@alice:example.com`). 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: The callback must return one of:
- `synapse.module_api.NOT_SPAM`, to allow the operation. Other callbacks may still - `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. 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` ### `user_may_send_3pid_invite`
_First introduced in Synapse v1.45.0_ _First introduced in Synapse v1.45.0_

View File

@@ -1062,8 +1062,8 @@ class FederationHandler:
if self.hs.config.server.block_non_admin_invites: if self.hs.config.server.block_non_admin_invites:
raise SynapseError(403, "This server does not accept room invites") raise SynapseError(403, "This server does not accept room invites")
spam_check = await self._spam_checker_module_callbacks.user_may_invite( spam_check = (
event.sender, event.state_key, event.room_id await self._spam_checker_module_callbacks.federated_user_may_invite(event)
) )
if spam_check != NOT_SPAM: if spam_check != NOT_SPAM:
raise SynapseError( raise SynapseError(

View File

@@ -104,6 +104,7 @@ from synapse.module_api.callbacks.spamchecker_callbacks import (
CHECK_MEDIA_FILE_FOR_SPAM_CALLBACK, CHECK_MEDIA_FILE_FOR_SPAM_CALLBACK,
CHECK_REGISTRATION_FOR_SPAM_CALLBACK, CHECK_REGISTRATION_FOR_SPAM_CALLBACK,
CHECK_USERNAME_FOR_SPAM_CALLBACK, CHECK_USERNAME_FOR_SPAM_CALLBACK,
FEDERATED_USER_MAY_INVITE_CALLBACK,
SHOULD_DROP_FEDERATED_EVENT_CALLBACK, SHOULD_DROP_FEDERATED_EVENT_CALLBACK,
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK, USER_MAY_CREATE_ROOM_ALIAS_CALLBACK,
USER_MAY_CREATE_ROOM_CALLBACK, USER_MAY_CREATE_ROOM_CALLBACK,
@@ -315,6 +316,7 @@ class ModuleApi:
] = None, ] = None,
user_may_join_room: Optional[USER_MAY_JOIN_ROOM_CALLBACK] = None, user_may_join_room: Optional[USER_MAY_JOIN_ROOM_CALLBACK] = None,
user_may_invite: Optional[USER_MAY_INVITE_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_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: Optional[USER_MAY_CREATE_ROOM_CALLBACK] = None,
user_may_create_room_alias: Optional[ user_may_create_room_alias: Optional[
@@ -338,6 +340,7 @@ class ModuleApi:
should_drop_federated_event=should_drop_federated_event, should_drop_federated_event=should_drop_federated_event,
user_may_join_room=user_may_join_room, user_may_join_room=user_may_join_room,
user_may_invite=user_may_invite, 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_send_3pid_invite=user_may_send_3pid_invite,
user_may_create_room=user_may_create_room, user_may_create_room=user_may_create_room,
user_may_create_room_alias=user_may_create_room_alias, user_may_create_room_alias=user_may_create_room_alias,

View File

@@ -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[ USER_MAY_SEND_3PID_INVITE_CALLBACK = Callable[
[str, str, str, str], [str, str, str, str],
Awaitable[ Awaitable[
@@ -266,6 +282,7 @@ def load_legacy_spam_checkers(hs: "synapse.server.HomeServer") -> None:
spam_checker_methods = { spam_checker_methods = {
"check_event_for_spam", "check_event_for_spam",
"user_may_invite", "user_may_invite",
"federated_user_may_invite",
"user_may_create_room", "user_may_create_room",
"user_may_create_room_alias", "user_may_create_room_alias",
"user_may_publish_room", "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_join_room_callbacks: List[USER_MAY_JOIN_ROOM_CALLBACK] = []
self._user_may_invite_callbacks: List[USER_MAY_INVITE_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[ self._user_may_send_3pid_invite_callbacks: List[
USER_MAY_SEND_3PID_INVITE_CALLBACK USER_MAY_SEND_3PID_INVITE_CALLBACK
] = [] ] = []
@@ -377,6 +397,7 @@ class SpamCheckerModuleApiCallbacks:
] = None, ] = None,
user_may_join_room: Optional[USER_MAY_JOIN_ROOM_CALLBACK] = None, user_may_join_room: Optional[USER_MAY_JOIN_ROOM_CALLBACK] = None,
user_may_invite: Optional[USER_MAY_INVITE_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_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: Optional[USER_MAY_CREATE_ROOM_CALLBACK] = None,
user_may_create_room_alias: Optional[ user_may_create_room_alias: Optional[
@@ -406,6 +427,11 @@ class SpamCheckerModuleApiCallbacks:
if user_may_invite is not None: if user_may_invite is not None:
self._user_may_invite_callbacks.append(user_may_invite) 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: if user_may_send_3pid_invite is not None:
self._user_may_send_3pid_invite_callbacks.append( self._user_may_send_3pid_invite_callbacks.append(
user_may_send_3pid_invite, user_may_send_3pid_invite,
@@ -605,6 +631,43 @@ class SpamCheckerModuleApiCallbacks:
# No spam-checker has rejected the request, let it pass. # No spam-checker has rejected the request, let it pass.
return self.NOT_SPAM 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( async def user_may_send_3pid_invite(
self, inviter_userid: str, medium: str, address: str, room_id: str self, inviter_userid: str, medium: str, address: str, room_id: str
) -> Union[Tuple[Codes, dict], Literal["NOT_SPAM"]]: ) -> Union[Tuple[Codes, dict], Literal["NOT_SPAM"]]: