Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 2e5c3f3

Browse files
committed
Add a spamchecker callback to allow or deny room creation based on invites (#10898)
This is in the context of creating new module callbacks that modules in https://github.com/matrix-org/synapse-dinsic can use, in an effort to reconcile the spam checker API in synapse-dinsic with the one in mainline. This adds a callback that's fairly similar to user_may_create_room except it also allows processing based on the invites sent at room creation.
1 parent dd19126 commit 2e5c3f3

File tree

5 files changed

+358
-17
lines changed

5 files changed

+358
-17
lines changed

changelog.d/10898.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a `user_may_create_room_with_invites` spam checker callback to allow modules to allow or deny a room creation request based on the invites and/or 3PID invites it includes.
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# Spam checker callbacks
2+
3+
Spam checker callbacks allow module developers to implement spam mitigation actions for
4+
Synapse instances. Spam checker callbacks can be registered using the module API's
5+
`register_spam_checker_callbacks` method.
6+
7+
## Callbacks
8+
9+
The available spam checker callbacks are:
10+
11+
### `check_event_for_spam`
12+
13+
```python
14+
async def check_event_for_spam(event: "synapse.events.EventBase") -> Union[bool, str]
15+
```
16+
17+
Called when receiving an event from a client or via federation. The module can return
18+
either a `bool` to indicate whether the event must be rejected because of spam, or a `str`
19+
to indicate the event must be rejected because of spam and to give a rejection reason to
20+
forward to clients.
21+
22+
### `user_may_invite`
23+
24+
```python
25+
async def user_may_invite(inviter: str, invitee: str, room_id: str) -> bool
26+
```
27+
28+
Called when processing an invitation. The module must return a `bool` indicating whether
29+
the inviter can invite the invitee to the given room. Both inviter and invitee are
30+
represented by their Matrix user ID (e.g. `@alice:example.com`).
31+
32+
### `user_may_create_room`
33+
34+
```python
35+
async def user_may_create_room(user: str) -> bool
36+
```
37+
38+
Called when processing a room creation request. The module must return a `bool` indicating
39+
whether the given user (represented by their Matrix user ID) is allowed to create a room.
40+
41+
### `user_may_create_room_with_invites`
42+
43+
```python
44+
async def user_may_create_room_with_invites(
45+
user: str,
46+
invites: List[str],
47+
threepid_invites: List[Dict[str, str]],
48+
) -> bool
49+
```
50+
51+
Called when processing a room creation request (right after `user_may_create_room`).
52+
The module is given the Matrix user ID of the user trying to create a room, as well as a
53+
list of Matrix users to invite and a list of third-party identifiers (3PID, e.g. email
54+
addresses) to invite.
55+
56+
An invited Matrix user to invite is represented by their Matrix user IDs, and an invited
57+
3PIDs is represented by a dict that includes the 3PID medium (e.g. "email") through its
58+
`medium` key and its address (e.g. "[email protected]") through its `address` key.
59+
60+
See [the Matrix specification](https://matrix.org/docs/spec/appendices#pid-types) for more
61+
information regarding third-party identifiers.
62+
63+
If no invite and/or 3PID invite were specified in the room creation request, the
64+
corresponding list(s) will be empty.
65+
66+
**Note**: This callback is not called when a room is cloned (e.g. during a room upgrade)
67+
since no invites are sent when cloning a room. To cover this case, modules also need to
68+
implement `user_may_create_room`.
69+
70+
### `user_may_create_room_alias`
71+
72+
```python
73+
async def user_may_create_room_alias(user: str, room_alias: "synapse.types.RoomAlias") -> bool
74+
```
75+
76+
Called when trying to associate an alias with an existing room. The module must return a
77+
`bool` indicating whether the given user (represented by their Matrix user ID) is allowed
78+
to set the given alias.
79+
80+
### `user_may_publish_room`
81+
82+
```python
83+
async def user_may_publish_room(user: str, room_id: str) -> bool
84+
```
85+
86+
Called when trying to publish a room to the homeserver's public rooms directory. The
87+
module must return a `bool` indicating whether the given user (represented by their
88+
Matrix user ID) is allowed to publish the given room.
89+
90+
### `check_username_for_spam`
91+
92+
```python
93+
async def check_username_for_spam(user_profile: Dict[str, str]) -> bool
94+
```
95+
96+
Called when computing search results in the user directory. The module must return a
97+
`bool` indicating whether the given user profile can appear in search results. The profile
98+
is represented as a dictionary with the following keys:
99+
100+
* `user_id`: The Matrix ID for this user.
101+
* `display_name`: The user's display name.
102+
* `avatar_url`: The `mxc://` URL to the user's avatar.
103+
104+
The module is given a copy of the original dictionary, so modifying it from within the
105+
module cannot modify a user's profile when included in user directory search results.
106+
107+
### `check_registration_for_spam`
108+
109+
```python
110+
async def check_registration_for_spam(
111+
email_threepid: Optional[dict],
112+
username: Optional[str],
113+
request_info: Collection[Tuple[str, str]],
114+
auth_provider_id: Optional[str] = None,
115+
) -> "synapse.spam_checker_api.RegistrationBehaviour"
116+
```
117+
118+
Called when registering a new user. The module must return a `RegistrationBehaviour`
119+
indicating whether the registration can go through or must be denied, or whether the user
120+
may be allowed to register but will be shadow banned.
121+
122+
The arguments passed to this callback are:
123+
124+
* `email_threepid`: The email address used for registering, if any.
125+
* `username`: The username the user would like to register. Can be `None`, meaning that
126+
Synapse will generate one later.
127+
* `request_info`: A collection of tuples, which first item is a user agent, and which
128+
second item is an IP address. These user agents and IP addresses are the ones that were
129+
used during the registration process.
130+
* `auth_provider_id`: The identifier of the SSO authentication provider, if any.
131+
132+
### `check_media_file_for_spam`
133+
134+
```python
135+
async def check_media_file_for_spam(
136+
file_wrapper: "synapse.rest.media.v1.media_storage.ReadableFileWrapper",
137+
file_info: "synapse.rest.media.v1._base.FileInfo",
138+
) -> bool
139+
```
140+
141+
Called when storing a local or remote file. The module must return a boolean indicating
142+
whether the given file can be stored in the homeserver's media store.
143+
144+
## Example
145+
146+
The example below is a module that implements the spam checker callback
147+
`check_event_for_spam` to deny any message sent by users whose Matrix user IDs are
148+
mentioned in a configured list, and registers a web resource to the path
149+
`/_synapse/client/list_spam_checker/is_evil` that returns a JSON object indicating
150+
whether the provided user appears in that list.
151+
152+
```python
153+
import json
154+
from typing import Union
155+
156+
from twisted.web.resource import Resource
157+
from twisted.web.server import Request
158+
159+
from synapse.module_api import ModuleApi
160+
161+
162+
class IsUserEvilResource(Resource):
163+
def __init__(self, config):
164+
super(IsUserEvilResource, self).__init__()
165+
self.evil_users = config.get("evil_users") or []
166+
167+
def render_GET(self, request: Request):
168+
user = request.args.get(b"user")[0].decode()
169+
request.setHeader(b"Content-Type", b"application/json")
170+
return json.dumps({"evil": user in self.evil_users}).encode()
171+
172+
173+
class ListSpamChecker:
174+
def __init__(self, config: dict, api: ModuleApi):
175+
self.api = api
176+
self.evil_users = config.get("evil_users") or []
177+
178+
self.api.register_spam_checker_callbacks(
179+
check_event_for_spam=self.check_event_for_spam,
180+
)
181+
182+
self.api.register_web_resource(
183+
path="/_synapse/client/list_spam_checker/is_evil",
184+
resource=IsUserEvilResource(config),
185+
)
186+
187+
async def check_event_for_spam(self, event: "synapse.events.EventBase") -> Union[bool, str]:
188+
return event.sender not in self.evil_users
189+
```

synapse/events/spamcheck.py

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,10 @@
4444
["synapse.events.EventBase"],
4545
Awaitable[Union[bool, str]],
4646
]
47-
# FIXME: Callback signature differs from mainline
48-
USER_MAY_INVITE_CALLBACK = Callable[
49-
[str, Optional[str], Optional[dict], str, bool, bool], Awaitable[bool]
50-
]
51-
# FIXME: Callback signature differs from mainline
52-
USER_MAY_CREATE_ROOM_CALLBACK = Callable[
53-
[str, List[str], List[dict], bool], Awaitable[bool]
47+
USER_MAY_INVITE_CALLBACK = Callable[[str, str, str], Awaitable[bool]]
48+
USER_MAY_CREATE_ROOM_CALLBACK = Callable[[str], Awaitable[bool]]
49+
USER_MAY_CREATE_ROOM_WITH_INVITES_CALLBACK = Callable[
50+
[str, List[str], List[Dict[str, str]]], Awaitable[bool]
5451
]
5552
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK = Callable[[str, RoomAlias], Awaitable[bool]]
5653
USER_MAY_PUBLISH_ROOM_CALLBACK = Callable[[str, str], Awaitable[bool]]
@@ -173,6 +170,9 @@ def __init__(self):
173170
self._check_event_for_spam_callbacks: List[CHECK_EVENT_FOR_SPAM_CALLBACK] = []
174171
self._user_may_invite_callbacks: List[USER_MAY_INVITE_CALLBACK] = []
175172
self._user_may_create_room_callbacks: List[USER_MAY_CREATE_ROOM_CALLBACK] = []
173+
self._user_may_create_room_with_invites_callbacks: List[
174+
USER_MAY_CREATE_ROOM_WITH_INVITES_CALLBACK
175+
] = []
176176
self._user_may_create_room_alias_callbacks: List[
177177
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK
178178
] = []
@@ -193,6 +193,9 @@ def register_callbacks(
193193
check_event_for_spam: Optional[CHECK_EVENT_FOR_SPAM_CALLBACK] = None,
194194
user_may_invite: Optional[USER_MAY_INVITE_CALLBACK] = None,
195195
user_may_create_room: Optional[USER_MAY_CREATE_ROOM_CALLBACK] = None,
196+
user_may_create_room_with_invites: Optional[
197+
USER_MAY_CREATE_ROOM_WITH_INVITES_CALLBACK
198+
] = None,
196199
user_may_create_room_alias: Optional[
197200
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK
198201
] = None,
@@ -214,6 +217,11 @@ def register_callbacks(
214217
if user_may_create_room is not None:
215218
self._user_may_create_room_callbacks.append(user_may_create_room)
216219

220+
if user_may_create_room_with_invites is not None:
221+
self._user_may_create_room_with_invites_callbacks.append(
222+
user_may_create_room_with_invites,
223+
)
224+
217225
if user_may_create_room_alias is not None:
218226
self._user_may_create_room_alias_callbacks.append(
219227
user_may_create_room_alias,
@@ -338,6 +346,34 @@ async def user_may_create_room(
338346

339347
return True
340348

349+
async def user_may_create_room_with_invites(
350+
self,
351+
userid: str,
352+
invites: List[str],
353+
threepid_invites: List[Dict[str, str]],
354+
) -> bool:
355+
"""Checks if a given user may create a room with invites
356+
357+
If this method returns false, the creation request will be rejected.
358+
359+
Args:
360+
userid: The ID of the user attempting to create a room
361+
invites: The IDs of the Matrix users to be invited if the room creation is
362+
allowed.
363+
threepid_invites: The threepids to be invited if the room creation is allowed,
364+
as a dict including a "medium" key indicating the threepid's medium (e.g.
365+
"email") and an "address" key indicating the threepid's address (e.g.
366+
367+
368+
Returns:
369+
True if the user may create the room, otherwise False
370+
"""
371+
for callback in self._user_may_create_room_with_invites_callbacks:
372+
if await callback(userid, invites, threepid_invites) is False:
373+
return False
374+
375+
return True
376+
341377
async def user_may_create_room_alias(
342378
self, userid: str, room_alias: RoomAlias
343379
) -> bool:

synapse/handlers/room.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -636,14 +636,16 @@ async def create_room(
636636
requester, config, is_requester_admin=is_requester_admin
637637
)
638638

639-
invite_list = config.get("invite", [])
640639
invite_3pid_list = config.get("invite_3pid", [])
640+
invite_list = config.get("invite", [])
641641

642-
if not is_requester_admin and not await self.spam_checker.user_may_create_room(
643-
user_id,
644-
invite_list=invite_list,
645-
third_party_invite_list=invite_3pid_list,
646-
cloning=False,
642+
if not is_requester_admin and not (
643+
await self.spam_checker.user_may_create_room(user_id)
644+
and await self.spam_checker.user_may_create_room_with_invites(
645+
user_id,
646+
invite_list,
647+
invite_3pid_list,
648+
)
647649
):
648650
raise SynapseError(403, "You are not permitted to create rooms")
649651

@@ -677,8 +679,6 @@ async def create_room(
677679
if mapping:
678680
raise SynapseError(400, "Room alias already taken", Codes.ROOM_IN_USE)
679681

680-
invite_3pid_list = config.get("invite_3pid", [])
681-
invite_list = config.get("invite", [])
682682
for i in invite_list:
683683
try:
684684
uid = UserID.from_string(i)

0 commit comments

Comments
 (0)