Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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.
1 change: 1 addition & 0 deletions changelog.d/18486.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Pass room_config argument to user_may_create_room spam checker module callback.
39 changes: 38 additions & 1 deletion docs/modules/spam_checker_callbacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,19 @@ _First introduced in Synapse v1.37.0_

_Changed in Synapse v1.62.0: `synapse.module_api.NOT_SPAM` and `synapse.module_api.errors.Codes` can be returned by this callback. Returning a boolean is now deprecated._

_Changed in Synapse v1.x.x: Added the `room_config` argument. Callbacks that only expect a single `user_id` argument are still supported._

```python
async def user_may_create_room(user_id: str) -> Union["synapse.module_api.NOT_SPAM", "synapse.module_api.errors.Codes", bool]
async def user_may_create_room(user_id: str, room_config: synapse.module_api.JsonDict) -> Union["synapse.module_api.NOT_SPAM", "synapse.module_api.errors.Codes", bool]
```

Called when processing a room creation request.

The arguments passed to this callback are:

* `user_id`: The Matrix user ID of the user (e.g. `@alice:example.com`).
* `room_config`: The contents of the body of a [/createRoom request](https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3createroom) as a dictionary.

The callback must return one of:
- `synapse.module_api.NOT_SPAM`, to allow the operation. Other callbacks may still
decide to reject it.
Expand Down Expand Up @@ -239,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 vX.X.X_

```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
2 changes: 1 addition & 1 deletion docs/spam_checker.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class ExampleSpamChecker:
async def user_may_invite(self, inviter_userid, invitee_userid, room_id):
return True # allow all invites

async def user_may_create_room(self, userid):
async def user_may_create_room(self, userid, room_config):
return True # allow all room creations

async def user_may_create_room_alias(self, userid, room_alias):
Expand Down
31 changes: 19 additions & 12 deletions synapse/handlers/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,17 +468,6 @@ async def clone_existing_room(
"""
user_id = requester.user.to_string()

spam_check = await self._spam_checker_module_callbacks.user_may_create_room(
user_id
)
if spam_check != self._spam_checker_module_callbacks.NOT_SPAM:
raise SynapseError(
403,
"You are not permitted to create rooms",
errcode=spam_check[0],
additional_fields=spam_check[1],
)

creation_content: JsonDict = {
"room_version": new_room_version.identifier,
"predecessor": {"room_id": old_room_id, "event_id": tombstone_event_id},
Expand Down Expand Up @@ -585,6 +574,24 @@ async def clone_existing_room(
if current_power_level_int < needed_power_level:
user_power_levels[user_id] = needed_power_level

# We construct what the body of a call to /createRoom would look like for passing
# to the spam checker. We don't include a preset here, as we expect the
# initial state to contain everything we need.
spam_check = await self._spam_checker_module_callbacks.user_may_create_room(
user_id,
{
"creation_content": creation_content,
"initial_state": list(initial_state.items()),
},
)
if spam_check != self._spam_checker_module_callbacks.NOT_SPAM:
raise SynapseError(
403,
"You are not permitted to create rooms",
errcode=spam_check[0],
additional_fields=spam_check[1],
)

await self._send_events_for_new_room(
requester,
new_room_id,
Expand Down Expand Up @@ -786,7 +793,7 @@ async def create_room(

if not is_requester_admin:
spam_check = await self._spam_checker_module_callbacks.user_may_create_room(
user_id
user_id, config
)
if spam_check != self._spam_checker_module_callbacks.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 @@ -103,6 +103,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 @@ -311,6 +312,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 @@ -335,6 +337,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
103 changes: 93 additions & 10 deletions synapse/module_api/callbacks/spamchecker_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,28 @@
]
],
]
USER_MAY_CREATE_ROOM_CALLBACK = Callable[
[str],
USER_MAY_CREATE_ROOM_CALLBACK_RETURN_VALUE = 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_CREATE_ROOM_CALLBACK = Union[
Callable[
[str, JsonDict],
Awaitable[USER_MAY_CREATE_ROOM_CALLBACK_RETURN_VALUE],
],
Callable[ # Single argument variant for backwards compatibility
[str], Awaitable[USER_MAY_CREATE_ROOM_CALLBACK_RETURN_VALUE]
],
]
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK = Callable[
[str, RoomAlias],
Awaitable[
Union[
Literal["NOT_SPAM"],
Expand All @@ -136,8 +156,8 @@
]
],
]
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK = Callable[
[str, RoomAlias],
USER_MAY_PUBLISH_ROOM_CALLBACK = Callable[
[str, str],
Awaitable[
Union[
Literal["NOT_SPAM"],
Expand All @@ -152,8 +172,8 @@
]
],
]
USER_MAY_PUBLISH_ROOM_CALLBACK = Callable[
[str, str],
USER_MAY_SEND_STATE_EVENT_CALLBACK = Callable[
[str, str, str, str, JsonDict],
Awaitable[
Union[
Literal["NOT_SPAM"],
Expand All @@ -163,8 +183,6 @@
# experiments.
# Use this to return additional information as part of an error.
Tuple[Codes, JsonDict],
# Deprecated
bool,
]
],
]
Expand Down Expand Up @@ -332,6 +350,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 @@ -367,6 +388,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 @@ -391,6 +413,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 @@ -622,16 +649,41 @@ async def user_may_send_3pid_invite(
return self.NOT_SPAM

async def user_may_create_room(
self, userid: str
self, userid: str, room_config: JsonDict
) -> Union[Tuple[Codes, dict], Literal["NOT_SPAM"]]:
"""Checks if a given user may create a room

Args:
userid: The ID of the user attempting to create a room
room_config: The room creation configuration which is the body of the /createRoom request
"""
for callback in self._user_may_create_room_callbacks:
with Measure(self.clock, f"{callback.__module__}.{callback.__qualname__}"):
res = await delay_cancellation(callback(userid))
checker_args = inspect.signature(callback)
# Also ensure backwards compatibility with spam checker callbacks
# that don't expect the room_config argument.
if len(checker_args.parameters) == 2:
callback_with_requester_id = cast(
Callable[
[str, JsonDict],
Awaitable[USER_MAY_CREATE_ROOM_CALLBACK_RETURN_VALUE],
],
callback,
)
# We make a copy of the config to ensure the spam checker cannot modify it.
res = await delay_cancellation(
callback_with_requester_id(userid, room_config.copy())
)
else:
callback_without_requester_id = cast(
Callable[
[str], Awaitable[USER_MAY_CREATE_ROOM_CALLBACK_RETURN_VALUE]
],
callback,
)
res = await delay_cancellation(
callback_without_requester_id(userid)
)
if res is True or res is self.NOT_SPAM:
continue
elif res is False:
Expand All @@ -653,6 +705,37 @@ async def user_may_create_room(

return self.NOT_SPAM

async def user_may_send_state_event(
self,
userid: 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:
userid: The ID of the user attempting to create a room
visibility: The visibility of the room to be created
"""
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(userid, room_id, event_type, state_key, content.copy())
)
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(
userid=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
Loading
Loading