Skip to content

Commit 1b3085c

Browse files
committed
Add user_may_send_state_event callback to spam checker module API
1 parent 2436512 commit 1b3085c

File tree

5 files changed

+103
-0
lines changed

5 files changed

+103
-0
lines changed

changelog.d/18455.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add user_may_send_state_event callback to spam checker module API.

docs/modules/spam_checker_callbacks.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,36 @@ be used. If this happens, Synapse will not call any of the subsequent implementa
239239
this callback.
240240

241241

242+
### `user_may_send_state_event`
243+
244+
_First introduced in Synapse vX.X.X_
245+
246+
```python
247+
async def user_may_send_state_event(user_id: str, room_id: str, event_type: str, state_key: str, content: JsonDict) -> Union["synapse.module_api.NOT_SPAM", "synapse.module_api.errors.Codes"]
248+
```
249+
250+
Called when processing a request to [send state events](https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey) to a room.
251+
252+
The arguments passed to this callback are:
253+
254+
* `user_id`: The Matrix user ID of the user (e.g. `@alice:example.com`) sending the state event.
255+
* `room_id`: The ID of the room that the requested state event is being sent to.
256+
* `event_type`: The requested type of event.
257+
* `state_key`: The requested state key.
258+
* `content`: The requested event contents.
259+
260+
The callback must return one of:
261+
- `synapse.module_api.NOT_SPAM`, to allow the operation. Other callbacks may still
262+
decide to reject it.
263+
- `synapse.module_api.errors.Codes` to reject the operation with an error code. In case
264+
of doubt, `synapse.module_api.errors.Codes.FORBIDDEN` is a good error code.
265+
266+
If multiple modules implement this callback, they will be considered in order. If a
267+
callback returns `synapse.module_api.NOT_SPAM`, Synapse falls through to the next one.
268+
The value of the first callback that does not return `synapse.module_api.NOT_SPAM` will
269+
be used. If this happens, Synapse will not call any of the subsequent implementations of
270+
this callback.
271+
242272

243273
### `check_username_for_spam`
244274

synapse/module_api/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
SHOULD_DROP_FEDERATED_EVENT_CALLBACK,
100100
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK,
101101
USER_MAY_CREATE_ROOM_CALLBACK,
102+
USER_MAY_SEND_STATE_EVENT_CALLBACK,
102103
USER_MAY_INVITE_CALLBACK,
103104
USER_MAY_JOIN_ROOM_CALLBACK,
104105
USER_MAY_PUBLISH_ROOM_CALLBACK,
@@ -311,6 +312,9 @@ def register_spam_checker_callbacks(
311312
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK
312313
] = None,
313314
user_may_publish_room: Optional[USER_MAY_PUBLISH_ROOM_CALLBACK] = None,
315+
user_may_send_state_event: Optional[
316+
USER_MAY_SEND_STATE_EVENT_CALLBACK
317+
] = None,
314318
check_username_for_spam: Optional[CHECK_USERNAME_FOR_SPAM_CALLBACK] = None,
315319
check_registration_for_spam: Optional[
316320
CHECK_REGISTRATION_FOR_SPAM_CALLBACK
@@ -335,6 +339,7 @@ def register_spam_checker_callbacks(
335339
check_registration_for_spam=check_registration_for_spam,
336340
check_media_file_for_spam=check_media_file_for_spam,
337341
check_login_for_spam=check_login_for_spam,
342+
user_may_send_state_event=user_may_send_state_event,
338343
)
339344

340345
def register_account_validity_callbacks(

synapse/module_api/callbacks/spamchecker_callbacks.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,20 @@
168168
]
169169
],
170170
]
171+
USER_MAY_SEND_STATE_EVENT_CALLBACK = Callable[
172+
[str, str, str, str, JsonDict],
173+
Awaitable[
174+
Union[
175+
Literal["NOT_SPAM"],
176+
Codes,
177+
# Highly experimental, not officially part of the spamchecker API, may
178+
# disappear without warning depending on the results of ongoing
179+
# experiments.
180+
# Use this to return additional information as part of an error.
181+
Tuple[Codes, JsonDict],
182+
]
183+
],
184+
]
171185
CHECK_USERNAME_FOR_SPAM_CALLBACK = Union[
172186
Callable[[UserProfile], Awaitable[bool]],
173187
Callable[[UserProfile, str], Awaitable[bool]],
@@ -332,6 +346,9 @@ def __init__(self, hs: "synapse.server.HomeServer") -> None:
332346
USER_MAY_SEND_3PID_INVITE_CALLBACK
333347
] = []
334348
self._user_may_create_room_callbacks: List[USER_MAY_CREATE_ROOM_CALLBACK] = []
349+
self._user_may_send_state_event_callbacks: List[
350+
USER_MAY_SEND_STATE_EVENT_CALLBACK
351+
] = []
335352
self._user_may_create_room_alias_callbacks: List[
336353
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK
337354
] = []
@@ -367,6 +384,9 @@ def register_callbacks(
367384
] = None,
368385
check_media_file_for_spam: Optional[CHECK_MEDIA_FILE_FOR_SPAM_CALLBACK] = None,
369386
check_login_for_spam: Optional[CHECK_LOGIN_FOR_SPAM_CALLBACK] = None,
387+
user_may_send_state_event: Optional[
388+
USER_MAY_SEND_STATE_EVENT_CALLBACK
389+
] = None,
370390
) -> None:
371391
"""Register callbacks from module for each hook."""
372392
if check_event_for_spam is not None:
@@ -391,6 +411,11 @@ def register_callbacks(
391411
if user_may_create_room is not None:
392412
self._user_may_create_room_callbacks.append(user_may_create_room)
393413

414+
if user_may_send_state_event is not None:
415+
self._user_may_send_state_event_callbacks.append(
416+
user_may_send_state_event,
417+
)
418+
394419
if user_may_create_room_alias is not None:
395420
self._user_may_create_room_alias_callbacks.append(
396421
user_may_create_room_alias,
@@ -653,6 +678,30 @@ async def user_may_create_room(
653678

654679
return self.NOT_SPAM
655680

681+
async def user_may_send_state_event(
682+
self, userid: str, room_id: str, event_type: str, state_key: str, content: JsonDict
683+
) -> Union[Tuple[Codes, dict], Literal["NOT_SPAM"]]:
684+
"""Checks if a given user may create a room with a given visibility
685+
Args:
686+
userid: The ID of the user attempting to create a room
687+
visibility: The visibility of the room to be created
688+
"""
689+
for callback in self._user_may_send_state_event_callbacks:
690+
with Measure(self.clock, f"{callback.__module__}.{callback.__qualname__}"):
691+
# We make a copy of the content to ensure that the spam checker cannot modify it.
692+
res = await delay_cancellation(callback(userid, room_id, event_type, state_key, content.copy()))
693+
if res is self.NOT_SPAM:
694+
continue
695+
elif isinstance(res, synapse.api.errors.Codes):
696+
return res, {}
697+
else:
698+
logger.warning(
699+
"Module returned invalid value, rejecting room creation as spam"
700+
)
701+
return synapse.api.errors.Codes.FORBIDDEN, {}
702+
703+
return self.NOT_SPAM
704+
656705
async def user_may_create_room_alias(
657706
self, userid: str, room_alias: RoomAlias
658707
) -> Union[Tuple[Codes, dict], Literal["NOT_SPAM"]]:

synapse/rest/client/room.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ def __init__(self, hs: "HomeServer"):
198198
self.delayed_events_handler = hs.get_delayed_events_handler()
199199
self.auth = hs.get_auth()
200200
self._max_event_delay_ms = hs.config.server.max_event_delay_ms
201+
self._spam_checker_module_callbacks = hs.get_module_api_callbacks().spam_checker
201202

202203
def register(self, http_server: HttpServer) -> None:
203204
# /rooms/$roomid/state/$eventtype
@@ -289,6 +290,23 @@ async def on_PUT(
289290

290291
content = parse_json_object_from_request(request)
291292

293+
is_requester_admin = await self.auth.is_server_admin(requester)
294+
if not is_requester_admin:
295+
spam_check = await self._spam_checker_module_callbacks.user_may_send_state_event(
296+
userid=requester.user.to_string(),
297+
room_id=room_id,
298+
event_type=event_type,
299+
state_key=state_key,
300+
content=content,
301+
)
302+
if spam_check != self._spam_checker_module_callbacks.NOT_SPAM:
303+
raise SynapseError(
304+
403,
305+
"You are not permitted to send the state event",
306+
errcode=spam_check[0],
307+
additional_fields=spam_check[1],
308+
)
309+
292310
origin_server_ts = None
293311
if requester.app_service:
294312
origin_server_ts = parse_integer(request, "ts")

0 commit comments

Comments
 (0)