Skip to content

Commit 434e389

Browse files
tulirspaetzanoadragon453
authored
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>
1 parent b139647 commit 434e389

5 files changed

Lines changed: 99 additions & 2 deletions

File tree

changelog.d/18241.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `federated_user_may_invite` spam checker callback which receives the entire invite event. Contributed by @tulir @ Beeper.

docs/modules/spam_checker_callbacks.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ Called when processing an invitation, both when one is created locally or when
8080
receiving an invite over federation. Both inviter and invitee are represented by
8181
their Matrix user ID (e.g. `@alice:example.com`).
8282

83+
Note that federated invites will call `federated_user_may_invite` before this callback.
84+
8385

8486
The callback must return one of:
8587
- `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
9799
this callback.
98100

99101

102+
### `federated_user_may_invite`
103+
104+
_First introduced in Synapse v1.133.0_
105+
106+
```python
107+
async def federated_user_may_invite(event: "synapse.events.EventBase") -> Union["synapse.module_api.NOT_SPAM", "synapse.module_api.errors.Codes", bool]
108+
```
109+
110+
Called when processing an invitation received over federation. Unlike `user_may_invite`,
111+
this callback receives the entire event, including any stripped state in the `unsigned`
112+
section, not just the room and user IDs.
113+
114+
The callback must return one of:
115+
- `synapse.module_api.NOT_SPAM`, to allow the operation. Other callbacks may still
116+
decide to reject it.
117+
- `synapse.module_api.errors.Codes` to reject the operation with an error code. In case
118+
of doubt, `synapse.module_api.errors.Codes.FORBIDDEN` is a good error code.
119+
120+
If multiple modules implement this callback, they will be considered in order. If a
121+
callback returns `synapse.module_api.NOT_SPAM`, Synapse falls through to the next one.
122+
The value of the first callback that does not return `synapse.module_api.NOT_SPAM` will
123+
be used. If this happens, Synapse will not call any of the subsequent implementations of
124+
this callback.
125+
126+
If all of the callbacks return `synapse.module_api.NOT_SPAM`, Synapse will also fall
127+
through to the `user_may_invite` callback before approving the invite.
128+
129+
100130
### `user_may_send_3pid_invite`
101131

102132
_First introduced in Synapse v1.45.0_

synapse/handlers/federation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,8 +1062,8 @@ async def on_invite_request(
10621062
if self.hs.config.server.block_non_admin_invites:
10631063
raise SynapseError(403, "This server does not accept room invites")
10641064

1065-
spam_check = await self._spam_checker_module_callbacks.user_may_invite(
1066-
event.sender, event.state_key, event.room_id
1065+
spam_check = (
1066+
await self._spam_checker_module_callbacks.federated_user_may_invite(event)
10671067
)
10681068
if spam_check != NOT_SPAM:
10691069
raise SynapseError(

synapse/module_api/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
CHECK_MEDIA_FILE_FOR_SPAM_CALLBACK,
105105
CHECK_REGISTRATION_FOR_SPAM_CALLBACK,
106106
CHECK_USERNAME_FOR_SPAM_CALLBACK,
107+
FEDERATED_USER_MAY_INVITE_CALLBACK,
107108
SHOULD_DROP_FEDERATED_EVENT_CALLBACK,
108109
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK,
109110
USER_MAY_CREATE_ROOM_CALLBACK,
@@ -315,6 +316,7 @@ def register_spam_checker_callbacks(
315316
] = None,
316317
user_may_join_room: Optional[USER_MAY_JOIN_ROOM_CALLBACK] = None,
317318
user_may_invite: Optional[USER_MAY_INVITE_CALLBACK] = None,
319+
federated_user_may_invite: Optional[FEDERATED_USER_MAY_INVITE_CALLBACK] = None,
318320
user_may_send_3pid_invite: Optional[USER_MAY_SEND_3PID_INVITE_CALLBACK] = None,
319321
user_may_create_room: Optional[USER_MAY_CREATE_ROOM_CALLBACK] = None,
320322
user_may_create_room_alias: Optional[
@@ -338,6 +340,7 @@ def register_spam_checker_callbacks(
338340
should_drop_federated_event=should_drop_federated_event,
339341
user_may_join_room=user_may_join_room,
340342
user_may_invite=user_may_invite,
343+
federated_user_may_invite=federated_user_may_invite,
341344
user_may_send_3pid_invite=user_may_send_3pid_invite,
342345
user_may_create_room=user_may_create_room,
343346
user_may_create_room_alias=user_may_create_room_alias,

synapse/module_api/callbacks/spamchecker_callbacks.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,22 @@
105105
]
106106
],
107107
]
108+
FEDERATED_USER_MAY_INVITE_CALLBACK = Callable[
109+
["synapse.events.EventBase"],
110+
Awaitable[
111+
Union[
112+
Literal["NOT_SPAM"],
113+
Codes,
114+
# Highly experimental, not officially part of the spamchecker API, may
115+
# disappear without warning depending on the results of ongoing
116+
# experiments.
117+
# Use this to return additional information as part of an error.
118+
Tuple[Codes, JsonDict],
119+
# Deprecated
120+
bool,
121+
]
122+
],
123+
]
108124
USER_MAY_SEND_3PID_INVITE_CALLBACK = Callable[
109125
[str, str, str, str],
110126
Awaitable[
@@ -266,6 +282,7 @@ def load_legacy_spam_checkers(hs: "synapse.server.HomeServer") -> None:
266282
spam_checker_methods = {
267283
"check_event_for_spam",
268284
"user_may_invite",
285+
"federated_user_may_invite",
269286
"user_may_create_room",
270287
"user_may_create_room_alias",
271288
"user_may_publish_room",
@@ -347,6 +364,9 @@ def __init__(self, hs: "synapse.server.HomeServer") -> None:
347364
] = []
348365
self._user_may_join_room_callbacks: List[USER_MAY_JOIN_ROOM_CALLBACK] = []
349366
self._user_may_invite_callbacks: List[USER_MAY_INVITE_CALLBACK] = []
367+
self._federated_user_may_invite_callbacks: List[
368+
FEDERATED_USER_MAY_INVITE_CALLBACK
369+
] = []
350370
self._user_may_send_3pid_invite_callbacks: List[
351371
USER_MAY_SEND_3PID_INVITE_CALLBACK
352372
] = []
@@ -377,6 +397,7 @@ def register_callbacks(
377397
] = None,
378398
user_may_join_room: Optional[USER_MAY_JOIN_ROOM_CALLBACK] = None,
379399
user_may_invite: Optional[USER_MAY_INVITE_CALLBACK] = None,
400+
federated_user_may_invite: Optional[FEDERATED_USER_MAY_INVITE_CALLBACK] = None,
380401
user_may_send_3pid_invite: Optional[USER_MAY_SEND_3PID_INVITE_CALLBACK] = None,
381402
user_may_create_room: Optional[USER_MAY_CREATE_ROOM_CALLBACK] = None,
382403
user_may_create_room_alias: Optional[
@@ -406,6 +427,11 @@ def register_callbacks(
406427
if user_may_invite is not None:
407428
self._user_may_invite_callbacks.append(user_may_invite)
408429

430+
if federated_user_may_invite is not None:
431+
self._federated_user_may_invite_callbacks.append(
432+
federated_user_may_invite,
433+
)
434+
409435
if user_may_send_3pid_invite is not None:
410436
self._user_may_send_3pid_invite_callbacks.append(
411437
user_may_send_3pid_invite,
@@ -605,6 +631,43 @@ async def user_may_invite(
605631
# No spam-checker has rejected the request, let it pass.
606632
return self.NOT_SPAM
607633

634+
async def federated_user_may_invite(
635+
self, event: "synapse.events.EventBase"
636+
) -> Union[Tuple[Codes, dict], Literal["NOT_SPAM"]]:
637+
"""Checks if a given user may send an invite
638+
639+
Args:
640+
event: The event to be checked
641+
642+
Returns:
643+
NOT_SPAM if the operation is permitted, Codes otherwise.
644+
"""
645+
for callback in self._federated_user_may_invite_callbacks:
646+
with Measure(self.clock, f"{callback.__module__}.{callback.__qualname__}"):
647+
res = await delay_cancellation(callback(event))
648+
# Normalize return values to `Codes` or `"NOT_SPAM"`.
649+
if res is True or res is self.NOT_SPAM:
650+
continue
651+
elif res is False:
652+
return synapse.api.errors.Codes.FORBIDDEN, {}
653+
elif isinstance(res, synapse.api.errors.Codes):
654+
return res, {}
655+
elif (
656+
isinstance(res, tuple)
657+
and len(res) == 2
658+
and isinstance(res[0], synapse.api.errors.Codes)
659+
and isinstance(res[1], dict)
660+
):
661+
return res
662+
else:
663+
logger.warning(
664+
"Module returned invalid value, rejecting invite as spam"
665+
)
666+
return synapse.api.errors.Codes.FORBIDDEN, {}
667+
668+
# Check the standard user_may_invite callback if no module has rejected the invite yet.
669+
return await self.user_may_invite(event.sender, event.state_key, event.room_id)
670+
608671
async def user_may_send_3pid_invite(
609672
self, inviter_userid: str, medium: str, address: str, room_id: str
610673
) -> Union[Tuple[Codes, dict], Literal["NOT_SPAM"]]:

0 commit comments

Comments
 (0)