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/18455.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add user_may_send_state_event callback to spam checker module API.
30 changes: 30 additions & 0 deletions docs/modules/spam_checker_callbacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,36 @@ be used. If this happens, Synapse will not call any of the subsequent implementa
this callback.


### `user_may_send_state_event`

_First introduced in Synapse v1.132.0_

```python
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"]
```

Called when processing a request to [send state events](https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey) to a room.

The arguments passed to this callback are:

* `user_id`: The Matrix user ID of the user (e.g. `@alice:example.com`) sending the state event.
* `room_id`: The ID of the room that the requested state event is being sent to.
* `event_type`: The requested type of event.
* `state_key`: The requested state key.
* `content`: The requested event contents.

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.


### `check_username_for_spam`

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 @@ -107,6 +107,7 @@
USER_MAY_JOIN_ROOM_CALLBACK,
USER_MAY_PUBLISH_ROOM_CALLBACK,
USER_MAY_SEND_3PID_INVITE_CALLBACK,
USER_MAY_SEND_STATE_EVENT_CALLBACK,
SpamCheckerModuleApiCallbacks,
)
from synapse.module_api.callbacks.third_party_event_rules_callbacks import (
Expand Down Expand Up @@ -315,6 +316,7 @@ def register_spam_checker_callbacks(
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK
] = None,
user_may_publish_room: Optional[USER_MAY_PUBLISH_ROOM_CALLBACK] = None,
user_may_send_state_event: Optional[USER_MAY_SEND_STATE_EVENT_CALLBACK] = None,
check_username_for_spam: Optional[CHECK_USERNAME_FOR_SPAM_CALLBACK] = None,
check_registration_for_spam: Optional[
CHECK_REGISTRATION_FOR_SPAM_CALLBACK
Expand All @@ -339,6 +341,7 @@ def register_spam_checker_callbacks(
check_registration_for_spam=check_registration_for_spam,
check_media_file_for_spam=check_media_file_for_spam,
check_login_for_spam=check_login_for_spam,
user_may_send_state_event=user_may_send_state_event,
)

def register_account_validity_callbacks(
Expand Down
57 changes: 57 additions & 0 deletions synapse/module_api/callbacks/spamchecker_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,20 @@
]
],
]
USER_MAY_SEND_STATE_EVENT_CALLBACK = Callable[
[str, str, str, str, JsonDict],
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],
]
],
]
CHECK_USERNAME_FOR_SPAM_CALLBACK = Union[
Callable[[UserProfile], Awaitable[bool]],
Callable[[UserProfile, str], Awaitable[bool]],
Expand Down Expand Up @@ -337,6 +351,9 @@ def __init__(self, hs: "synapse.server.HomeServer") -> None:
USER_MAY_SEND_3PID_INVITE_CALLBACK
] = []
self._user_may_create_room_callbacks: List[USER_MAY_CREATE_ROOM_CALLBACK] = []
self._user_may_send_state_event_callbacks: List[
USER_MAY_SEND_STATE_EVENT_CALLBACK
] = []
self._user_may_create_room_alias_callbacks: List[
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK
] = []
Expand Down Expand Up @@ -372,6 +389,7 @@ def register_callbacks(
] = None,
check_media_file_for_spam: Optional[CHECK_MEDIA_FILE_FOR_SPAM_CALLBACK] = None,
check_login_for_spam: Optional[CHECK_LOGIN_FOR_SPAM_CALLBACK] = None,
user_may_send_state_event: Optional[USER_MAY_SEND_STATE_EVENT_CALLBACK] = None,
) -> None:
"""Register callbacks from module for each hook."""
if check_event_for_spam is not None:
Expand All @@ -396,6 +414,11 @@ def register_callbacks(
if user_may_create_room is not None:
self._user_may_create_room_callbacks.append(user_may_create_room)

if user_may_send_state_event is not None:
self._user_may_send_state_event_callbacks.append(
user_may_send_state_event,
)

if user_may_create_room_alias is not None:
self._user_may_create_room_alias_callbacks.append(
user_may_create_room_alias,
Expand Down Expand Up @@ -683,6 +706,40 @@ async def user_may_create_room(

return self.NOT_SPAM

async def user_may_send_state_event(
self,
user_id: str,
room_id: str,
event_type: str,
state_key: str,
content: JsonDict,
) -> Union[Tuple[Codes, dict], Literal["NOT_SPAM"]]:
"""Checks if a given user may create a room with a given visibility
Args:
user_id: The ID of the user attempting to create a room
room_id: The ID of the room that the event will be sent to
event_type: The type of the state event
state_key: The state key of the state event
content: The content of the state event
"""
for callback in self._user_may_send_state_event_callbacks:
with Measure(self.clock, f"{callback.__module__}.{callback.__qualname__}"):
# We make a copy of the content to ensure that the spam checker cannot modify it.
res = await delay_cancellation(
callback(user_id, room_id, event_type, state_key, deepcopy(content))
)
if res is self.NOT_SPAM:
continue
elif isinstance(res, synapse.api.errors.Codes):
return res, {}
else:
logger.warning(
"Module returned invalid value, rejecting room creation as spam"
)
return synapse.api.errors.Codes.FORBIDDEN, {}

return self.NOT_SPAM

async def user_may_create_room_alias(
self, userid: str, room_alias: RoomAlias
) -> Union[Tuple[Codes, dict], Literal["NOT_SPAM"]]:
Expand Down
20 changes: 20 additions & 0 deletions synapse/rest/client/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ def __init__(self, hs: "HomeServer"):
self.delayed_events_handler = hs.get_delayed_events_handler()
self.auth = hs.get_auth()
self._max_event_delay_ms = hs.config.server.max_event_delay_ms
self._spam_checker_module_callbacks = hs.get_module_api_callbacks().spam_checker

def register(self, http_server: HttpServer) -> None:
# /rooms/$roomid/state/$eventtype
Expand Down Expand Up @@ -289,6 +290,25 @@ async def on_PUT(

content = parse_json_object_from_request(request)

is_requester_admin = await self.auth.is_server_admin(requester)
if not is_requester_admin:
spam_check = (
await self._spam_checker_module_callbacks.user_may_send_state_event(
user_id=requester.user.to_string(),
room_id=room_id,
event_type=event_type,
state_key=state_key,
content=content,
)
)
if spam_check != self._spam_checker_module_callbacks.NOT_SPAM:
raise SynapseError(
403,
"You are not permitted to send the state event",
errcode=spam_check[0],
additional_fields=spam_check[1],
)

origin_server_ts = None
if requester.app_service:
origin_server_ts = parse_integer(request, "ts")
Expand Down
89 changes: 89 additions & 0 deletions tests/module_api/test_spamchecker.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,92 @@ async def user_may_create_room(
channel = self.create_room({"foo": "baa"})
self.assertEqual(channel.code, 200)
self.assertEqual(self.last_user_id, self.user_id)

def test_user_may_send_state_event(self) -> None:
"""Test that the user_may_send_state_event callback is called when a state event
is sent, and that it receives the correct parameters.
"""

async def user_may_send_state_event(
user_id: str,
room_id: str,
event_type: str,
state_key: str,
content: JsonDict,
) -> Union[Literal["NOT_SPAM"], Codes]:
self.last_user_id = user_id
self.last_room_id = room_id
self.last_event_type = event_type
self.last_state_key = state_key
self.last_content = content
return "NOT_SPAM"

self._module_api.register_spam_checker_callbacks(
user_may_send_state_event=user_may_send_state_event
)

channel = self.create_room({})
self.assertEqual(channel.code, 200)

room_id = channel.json_body["room_id"]

event_type = "test.event.type"
state_key = "test.state.key"
channel = self.make_request(
"PUT",
"/_matrix/client/r0/rooms/%s/state/%s/%s"
% (
room_id,
event_type,
state_key,
),
content={"foo": "bar"},
access_token=self.token,
)

self.assertEqual(channel.code, 200)
self.assertEqual(self.last_user_id, self.user_id)
self.assertEqual(self.last_room_id, room_id)
self.assertEqual(self.last_event_type, event_type)
self.assertEqual(self.last_state_key, state_key)
self.assertEqual(self.last_content, {"foo": "bar"})

def test_user_may_send_state_event_disallows(self) -> None:
"""Test that the user_may_send_state_event callback is called when a state event
is sent, and that the response is honoured.
"""

async def user_may_send_state_event(
user_id: str,
room_id: str,
event_type: str,
state_key: str,
content: JsonDict,
) -> Union[Literal["NOT_SPAM"], Codes]:
return Codes.FORBIDDEN

self._module_api.register_spam_checker_callbacks(
user_may_send_state_event=user_may_send_state_event
)

channel = self.create_room({})
self.assertEqual(channel.code, 200)

room_id = channel.json_body["room_id"]

event_type = "test.event.type"
state_key = "test.state.key"
channel = self.make_request(
"PUT",
"/_matrix/client/r0/rooms/%s/state/%s/%s"
% (
room_id,
event_type,
state_key,
),
content={"foo": "bar"},
access_token=self.token,
)

self.assertEqual(channel.code, 403)
self.assertEqual(channel.json_body["errcode"], Codes.FORBIDDEN)
Loading