Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/18241.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `federated_user_may_invite` spam checker callback which receives the entire invite event. Contributed by @tulir @ Beeper.
30 changes: 30 additions & 0 deletions docs/modules/spam_checker_callbacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Comment thread
anoadragon453 marked this conversation as resolved.
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_
Expand Down
4 changes: 2 additions & 2 deletions synapse/handlers/federation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1062,8 +1062,8 @@ async def on_invite_request(
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)
Comment thread
anoadragon453 marked this conversation as resolved.
)
if spam_check != NOT_SPAM:
raise SynapseError(
Expand Down
3 changes: 3 additions & 0 deletions synapse/module_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
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,
Expand Down Expand Up @@ -315,6 +316,7 @@ def register_spam_checker_callbacks(
] = 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[
Expand All @@ -338,6 +340,7 @@ def register_spam_checker_callbacks(
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,
Expand Down
63 changes: 63 additions & 0 deletions synapse/module_api/callbacks/spamchecker_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,22 @@
]
],
]
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[
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -347,6 +364,9 @@ def __init__(self, hs: "synapse.server.HomeServer") -> None:
] = []
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
] = []
Expand Down Expand Up @@ -377,6 +397,7 @@ def register_callbacks(
] = 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[
Expand Down Expand Up @@ -406,6 +427,11 @@ def register_callbacks(
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,
Expand Down Expand Up @@ -605,6 +631,43 @@ async def user_may_invite(
# 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"]]:
Expand Down
Loading