1
0

Merge pull request #108 from matrix-org/babolivier/update_spam_checker

Bring spamchecker to parity with mainline
This commit is contained in:
Brendan Abolivier
2021-11-11 13:52:24 +01:00
committed by GitHub
15 changed files with 1045 additions and 290 deletions

View File

@@ -0,0 +1 @@
Add a `user_may_send_3pid_invite` spam checker callback for modules to allow or deny 3PID invites.

View File

@@ -0,0 +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.

View File

@@ -0,0 +1 @@
Add a spam checker callback to allow or deny room joins.

View File

@@ -0,0 +1 @@
Add a module API method to retrieve the current state of a room.

View File

@@ -0,0 +1,239 @@
# Spam checker callbacks
Spam checker callbacks allow module developers to implement spam mitigation actions for
Synapse instances. Spam checker callbacks can be registered using the module API's
`register_spam_checker_callbacks` method.
## Callbacks
The available spam checker callbacks are:
### `check_event_for_spam`
```python
async def check_event_for_spam(event: "synapse.events.EventBase") -> Union[bool, str]
```
Called when receiving an event from a client or via federation. The module can return
either a `bool` to indicate whether the event must be rejected because of spam, or a `str`
to indicate the event must be rejected because of spam and to give a rejection reason to
forward to clients.
### `user_may_join_room`
```python
async def user_may_join_room(user: str, room: str, is_invited: bool) -> bool
```
Called when a user is trying to join a room. The module must return a `bool` to indicate
whether the user can join the room. The user is represented by their Matrix user ID (e.g.
`@alice:example.com`) and the room is represented by its Matrix ID (e.g.
`!room:example.com`). The module is also given a boolean to indicate whether the user
currently has a pending invite in the room.
This callback isn't called if the join is performed by a server administrator, or in the
context of a room creation.
### `user_may_invite`
```python
async def user_may_invite(inviter: str, invitee: str, room_id: str) -> bool
```
Called when processing an invitation. The module must return a `bool` indicating whether
the inviter can invite the invitee to the given room. Both inviter and invitee are
represented by their Matrix user ID (e.g. `@alice:example.com`).
### `user_may_send_3pid_invite`
```python
async def user_may_send_3pid_invite(
inviter: str,
medium: str,
address: str,
room_id: str,
) -> bool
```
Called when processing an invitation using a third-party identifier (also called a 3PID,
e.g. an email address or a phone number). The module must return a `bool` indicating
whether the inviter can invite the invitee to the given room.
The inviter is represented by their Matrix user ID (e.g. `@alice:example.com`), and the
invitee is represented by its medium (e.g. "email") and its address
(e.g. `alice@example.com`). See [the Matrix specification](https://matrix.org/docs/spec/appendices#pid-types)
for more information regarding third-party identifiers.
For example, a call to this callback to send an invitation to the email address
`alice@example.com` would look like this:
```python
await user_may_send_3pid_invite(
"@bob:example.com", # The inviter's user ID
"email", # The medium of the 3PID to invite
"alice@example.com", # The address of the 3PID to invite
"!some_room:example.com", # The ID of the room to send the invite into
)
```
**Note**: If the third-party identifier is already associated with a matrix user ID,
[`user_may_invite`](#user_may_invite) will be used instead.
### `user_may_create_room`
```python
async def user_may_create_room(user: str) -> bool
```
Called when processing a room creation request. The module must return a `bool` indicating
whether the given user (represented by their Matrix user ID) is allowed to create a room.
### `user_may_create_room_with_invites`
```python
async def user_may_create_room_with_invites(
user: str,
invites: List[str],
threepid_invites: List[Dict[str, str]],
) -> bool
```
Called when processing a room creation request (right after `user_may_create_room`).
The module is given the Matrix user ID of the user trying to create a room, as well as a
list of Matrix users to invite and a list of third-party identifiers (3PID, e.g. email
addresses) to invite.
An invited Matrix user to invite is represented by their Matrix user IDs, and an invited
3PIDs is represented by a dict that includes the 3PID medium (e.g. "email") through its
`medium` key and its address (e.g. "alice@example.com") through its `address` key.
See [the Matrix specification](https://matrix.org/docs/spec/appendices#pid-types) for more
information regarding third-party identifiers.
If no invite and/or 3PID invite were specified in the room creation request, the
corresponding list(s) will be empty.
**Note**: This callback is not called when a room is cloned (e.g. during a room upgrade)
since no invites are sent when cloning a room. To cover this case, modules also need to
implement `user_may_create_room`.
### `user_may_create_room_alias`
```python
async def user_may_create_room_alias(user: str, room_alias: "synapse.types.RoomAlias") -> bool
```
Called when trying to associate an alias with an existing room. The module must return a
`bool` indicating whether the given user (represented by their Matrix user ID) is allowed
to set the given alias.
### `user_may_publish_room`
```python
async def user_may_publish_room(user: str, room_id: str) -> bool
```
Called when trying to publish a room to the homeserver's public rooms directory. The
module must return a `bool` indicating whether the given user (represented by their
Matrix user ID) is allowed to publish the given room.
### `check_username_for_spam`
```python
async def check_username_for_spam(user_profile: Dict[str, str]) -> bool
```
Called when computing search results in the user directory. The module must return a
`bool` indicating whether the given user profile can appear in search results. The profile
is represented as a dictionary with the following keys:
* `user_id`: The Matrix ID for this user.
* `display_name`: The user's display name.
* `avatar_url`: The `mxc://` URL to the user's avatar.
The module is given a copy of the original dictionary, so modifying it from within the
module cannot modify a user's profile when included in user directory search results.
### `check_registration_for_spam`
```python
async def check_registration_for_spam(
email_threepid: Optional[dict],
username: Optional[str],
request_info: Collection[Tuple[str, str]],
auth_provider_id: Optional[str] = None,
) -> "synapse.spam_checker_api.RegistrationBehaviour"
```
Called when registering a new user. The module must return a `RegistrationBehaviour`
indicating whether the registration can go through or must be denied, or whether the user
may be allowed to register but will be shadow banned.
The arguments passed to this callback are:
* `email_threepid`: The email address used for registering, if any.
* `username`: The username the user would like to register. Can be `None`, meaning that
Synapse will generate one later.
* `request_info`: A collection of tuples, which first item is a user agent, and which
second item is an IP address. These user agents and IP addresses are the ones that were
used during the registration process.
* `auth_provider_id`: The identifier of the SSO authentication provider, if any.
### `check_media_file_for_spam`
```python
async def check_media_file_for_spam(
file_wrapper: "synapse.rest.media.v1.media_storage.ReadableFileWrapper",
file_info: "synapse.rest.media.v1._base.FileInfo",
) -> bool
```
Called when storing a local or remote file. The module must return a boolean indicating
whether the given file can be stored in the homeserver's media store.
## Example
The example below is a module that implements the spam checker callback
`check_event_for_spam` to deny any message sent by users whose Matrix user IDs are
mentioned in a configured list, and registers a web resource to the path
`/_synapse/client/list_spam_checker/is_evil` that returns a JSON object indicating
whether the provided user appears in that list.
```python
import json
from typing import Union
from twisted.web.resource import Resource
from twisted.web.server import Request
from synapse.module_api import ModuleApi
class IsUserEvilResource(Resource):
def __init__(self, config):
super(IsUserEvilResource, self).__init__()
self.evil_users = config.get("evil_users") or []
def render_GET(self, request: Request):
user = request.args.get(b"user")[0].decode()
request.setHeader(b"Content-Type", b"application/json")
return json.dumps({"evil": user in self.evil_users}).encode()
class ListSpamChecker:
def __init__(self, config: dict, api: ModuleApi):
self.api = api
self.evil_users = config.get("evil_users") or []
self.api.register_spam_checker_callbacks(
check_event_for_spam=self.check_event_for_spam,
)
self.api.register_web_resource(
path="/_synapse/client/list_spam_checker/is_evil",
resource=IsUserEvilResource(config),
)
async def check_event_for_spam(self, event: "synapse.events.EventBase") -> Union[bool, str]:
return event.sender not in self.evil_users
```

View File

@@ -44,13 +44,12 @@ CHECK_EVENT_FOR_SPAM_CALLBACK = Callable[
["synapse.events.EventBase"],
Awaitable[Union[bool, str]],
]
# FIXME: Callback signature differs from mainline
USER_MAY_INVITE_CALLBACK = Callable[
[str, Optional[str], Optional[dict], str, bool, bool], Awaitable[bool]
]
# FIXME: Callback signature differs from mainline
USER_MAY_CREATE_ROOM_CALLBACK = Callable[
[str, List[str], List[dict], bool], Awaitable[bool]
USER_MAY_JOIN_ROOM_CALLBACK = Callable[[str, str, bool], Awaitable[bool]]
USER_MAY_INVITE_CALLBACK = Callable[[str, str, str], Awaitable[bool]]
USER_MAY_SEND_3PID_INVITE_CALLBACK = Callable[[str, str, str, str], Awaitable[bool]]
USER_MAY_CREATE_ROOM_CALLBACK = Callable[[str], Awaitable[bool]]
USER_MAY_CREATE_ROOM_WITH_INVITES_CALLBACK = Callable[
[str, List[str], List[Dict[str, str]]], Awaitable[bool]
]
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK = Callable[[str, RoomAlias], Awaitable[bool]]
USER_MAY_PUBLISH_ROOM_CALLBACK = Callable[[str, str], Awaitable[bool]]
@@ -76,17 +75,15 @@ CHECK_MEDIA_FILE_FOR_SPAM_CALLBACK = Callable[
[ReadableFileWrapper, FileInfo],
Awaitable[bool],
]
# FIXME: This callback only exists on the DINUM fork and not in mainline.
USER_MAY_JOIN_ROOM_CALLBACK = Callable[[str, str, bool], Awaitable[bool]]
def load_legacy_spam_checkers(hs: "synapse.server.HomeServer"):
def load_legacy_spam_checkers(hs: "synapse.server.HomeServer") -> None:
"""Wrapper that loads spam checkers configured using the old configuration, and
registers the spam checker hooks they implement.
"""
spam_checkers: List[Any] = []
api = hs.get_module_api()
for module, config in hs.config.spam_checkers:
for module, config in hs.config.spamchecker.spam_checkers:
# Older spam checkers don't accept the `api` argument, so we
# try and detect support.
spam_args = inspect.getfullargspec(module)
@@ -106,7 +103,6 @@ def load_legacy_spam_checkers(hs: "synapse.server.HomeServer"):
"check_username_for_spam",
"check_registration_for_spam",
"check_media_file_for_spam",
"user_may_join_room",
}
for spam_checker in spam_checkers:
@@ -133,9 +129,9 @@ def load_legacy_spam_checkers(hs: "synapse.server.HomeServer"):
request_info: Collection[Tuple[str, str]],
auth_provider_id: Optional[str],
) -> Union[Awaitable[RegistrationBehaviour], RegistrationBehaviour]:
# We've already made sure f is not None above, but mypy doesn't
# do well across function boundaries so we need to tell it f is
# definitely not None.
# Assertion required because mypy can't prove we won't
# change `f` back to `None`. See
# https://mypy.readthedocs.io/en/latest/common_issues.html#narrowing-and-inner-functions
assert f is not None
return f(
@@ -150,9 +146,10 @@ def load_legacy_spam_checkers(hs: "synapse.server.HomeServer"):
"Bad signature for callback check_registration_for_spam",
)
def run(*args, **kwargs):
# mypy doesn't do well across function boundaries so we need to tell it
# wrapped_func is definitely not None.
def run(*args: Any, **kwargs: Any) -> Awaitable:
# Assertion required because mypy can't prove we won't change `f`
# back to `None`. See
# https://mypy.readthedocs.io/en/latest/common_issues.html#narrowing-and-inner-functions
assert wrapped_func is not None
return maybe_awaitable(wrapped_func(*args, **kwargs))
@@ -169,10 +166,17 @@ def load_legacy_spam_checkers(hs: "synapse.server.HomeServer"):
class SpamChecker:
def __init__(self):
def __init__(self) -> None:
self._check_event_for_spam_callbacks: List[CHECK_EVENT_FOR_SPAM_CALLBACK] = []
self._user_may_join_room_callbacks: List[USER_MAY_JOIN_ROOM_CALLBACK] = []
self._user_may_invite_callbacks: List[USER_MAY_INVITE_CALLBACK] = []
self._user_may_send_3pid_invite_callbacks: List[
USER_MAY_SEND_3PID_INVITE_CALLBACK
] = []
self._user_may_create_room_callbacks: List[USER_MAY_CREATE_ROOM_CALLBACK] = []
self._user_may_create_room_with_invites_callbacks: List[
USER_MAY_CREATE_ROOM_WITH_INVITES_CALLBACK
] = []
self._user_may_create_room_alias_callbacks: List[
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK
] = []
@@ -186,13 +190,17 @@ class SpamChecker:
self._check_media_file_for_spam_callbacks: List[
CHECK_MEDIA_FILE_FOR_SPAM_CALLBACK
] = []
self._user_may_join_room_callbacks: List[USER_MAY_JOIN_ROOM_CALLBACK] = []
def register_callbacks(
self,
check_event_for_spam: Optional[CHECK_EVENT_FOR_SPAM_CALLBACK] = None,
user_may_join_room: Optional[USER_MAY_JOIN_ROOM_CALLBACK] = None,
user_may_invite: Optional[USER_MAY_INVITE_CALLBACK] = None,
user_may_send_3pid_invite: Optional[USER_MAY_SEND_3PID_INVITE_CALLBACK] = None,
user_may_create_room: Optional[USER_MAY_CREATE_ROOM_CALLBACK] = None,
user_may_create_room_with_invites: Optional[
USER_MAY_CREATE_ROOM_WITH_INVITES_CALLBACK
] = None,
user_may_create_room_alias: Optional[
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK
] = None,
@@ -202,18 +210,30 @@ class SpamChecker:
CHECK_REGISTRATION_FOR_SPAM_CALLBACK
] = None,
check_media_file_for_spam: Optional[CHECK_MEDIA_FILE_FOR_SPAM_CALLBACK] = None,
user_may_join_room: Optional[USER_MAY_JOIN_ROOM_CALLBACK] = None,
):
) -> None:
"""Register callbacks from module for each hook."""
if check_event_for_spam is not None:
self._check_event_for_spam_callbacks.append(check_event_for_spam)
if user_may_join_room is not None:
self._user_may_join_room_callbacks.append(user_may_join_room)
if user_may_invite is not None:
self._user_may_invite_callbacks.append(user_may_invite)
if user_may_send_3pid_invite is not None:
self._user_may_send_3pid_invite_callbacks.append(
user_may_send_3pid_invite,
)
if user_may_create_room is not None:
self._user_may_create_room_callbacks.append(user_may_create_room)
if user_may_create_room_with_invites is not None:
self._user_may_create_room_with_invites_callbacks.append(
user_may_create_room_with_invites,
)
if user_may_create_room_alias is not None:
self._user_may_create_room_alias_callbacks.append(
user_may_create_room_alias,
@@ -233,9 +253,6 @@ class SpamChecker:
if check_media_file_for_spam is not None:
self._check_media_file_for_spam_callbacks.append(check_media_file_for_spam)
if user_may_join_room is not None:
self._user_may_join_room_callbacks.append(user_may_join_room)
async def check_event_for_spam(
self, event: "synapse.events.EventBase"
) -> Union[bool, str]:
@@ -259,81 +276,113 @@ class SpamChecker:
return False
async def user_may_join_room(
self, user_id: str, room_id: str, is_invited: bool
) -> bool:
"""Checks if a given users is allowed to join a room.
Not called when a user creates a room.
Args:
userid: The ID of the user wanting to join the room
room_id: The ID of the room the user wants to join
is_invited: Whether the user is invited into the room
Returns:
Whether the user may join the room
"""
for callback in self._user_may_join_room_callbacks:
if await callback(user_id, room_id, is_invited) is False:
return False
return True
async def user_may_invite(
self,
inviter_userid: str,
invitee_userid: Optional[str],
third_party_invite: Optional[Dict],
room_id: str,
new_room: bool,
published_room: bool,
self, inviter_userid: str, invitee_userid: str, room_id: str
) -> bool:
"""Checks if a given user may send an invite
If this method returns false, the invite will be rejected.
Args:
inviter_userid:
invitee_userid: The user ID of the invitee. Is None
if this is a third party invite and the 3PID is not bound to a
user ID.
third_party_invite: If a third party invite then is a
dict containing the medium and address of the invitee.
room_id:
new_room: Whether the user is being invited to the room as
part of a room creation, if so the invitee would have been
included in the call to `user_may_create_room`.
published_room: Whether the room the user is being invited
to has been published in the local homeserver's public room
directory.
inviter_userid: The user ID of the sender of the invitation
invitee_userid: The user ID targeted in the invitation
room_id: The room ID
Returns:
True if the user may send an invite, otherwise False
"""
for callback in self._user_may_invite_callbacks:
if (
await callback(
inviter_userid,
invitee_userid,
third_party_invite,
room_id,
new_room,
published_room,
)
is False
):
if await callback(inviter_userid, invitee_userid, room_id) is False:
return False
return True
async def user_may_create_room(
self,
userid: str,
invite_list: List[str],
third_party_invite_list: List[Dict],
cloning: bool,
async def user_may_send_3pid_invite(
self, inviter_userid: str, medium: str, address: str, room_id: str
) -> bool:
"""Checks if a given user may invite a given threepid into the room
If this method returns false, the threepid invite will be rejected.
Note that if the threepid is already associated with a Matrix user ID, Synapse
will call user_may_invite with said user ID instead.
Args:
inviter_userid: The user ID of the sender of the invitation
medium: The 3PID's medium (e.g. "email")
address: The 3PID's address (e.g. "alice@example.com")
room_id: The room ID
Returns:
True if the user may send the invite, otherwise False
"""
for callback in self._user_may_send_3pid_invite_callbacks:
if await callback(inviter_userid, medium, address, room_id) is False:
return False
return True
async def user_may_create_room(self, userid: str) -> bool:
"""Checks if a given user may create a room
If this method returns false, the creation request will be rejected.
Args:
userid: The ID of the user attempting to create a room
invite_list: List of user IDs that would be invited to
the new room.
third_party_invite_list: List of third party invites
for the new room.
cloning: Whether the user is cloning an existing room, e.g.
upgrading a room.
Returns:
True if the user may create a room, otherwise False
"""
for callback in self._user_may_create_room_callbacks:
if (
await callback(userid, invite_list, third_party_invite_list, cloning)
is False
):
if await callback(userid) is False:
return False
return True
async def user_may_create_room_with_invites(
self,
userid: str,
invites: List[str],
threepid_invites: List[Dict[str, str]],
) -> bool:
"""Checks if a given user may create a room with invites
If this method returns false, the creation request will be rejected.
Args:
userid: The ID of the user attempting to create a room
invites: The IDs of the Matrix users to be invited if the room creation is
allowed.
threepid_invites: The threepids to be invited if the room creation is allowed,
as a dict including a "medium" key indicating the threepid's medium (e.g.
"email") and an "address" key indicating the threepid's address (e.g.
"alice@example.com")
Returns:
True if the user may create the room, otherwise False
"""
for callback in self._user_may_create_room_with_invites_callbacks:
if await callback(userid, invites, threepid_invites) is False:
return False
return True
@@ -376,25 +425,6 @@ class SpamChecker:
return True
async def user_may_join_room(self, userid: str, room_id: str, is_invited: bool):
"""Checks if a given users is allowed to join a room.
Not called when a user creates a room.
Args:
userid:
room_id:
is_invited: Whether the user is invited into the room
Returns:
bool: Whether the user may join the room
"""
for callback in self._user_may_join_room_callbacks:
if await callback(userid, room_id, is_invited) is False:
return False
return True
async def check_username_for_spam(self, user_profile: Dict[str, str]) -> bool:
"""Checks if a user ID or display name are considered "spammy" by this server.

View File

@@ -1893,15 +1893,8 @@ class FederationHandler(BaseHandler):
if self.hs.config.block_non_admin_invites:
raise SynapseError(403, "This server does not accept room invites")
is_published = await self.store.is_room_published(event.room_id)
if not await self.spam_checker.user_may_invite(
event.sender,
event.state_key,
None,
room_id=event.room_id,
new_room=False,
published_room=is_published,
event.sender, event.state_key, event.room_id
):
raise SynapseError(
403, "This user is not permitted to send invites to this server/user"

View File

@@ -374,19 +374,7 @@ class RoomCreationHandler(BaseHandler):
"""
user_id = requester.user.to_string()
if (
self._server_notices_mxid is not None
and requester.user.to_string() == self._server_notices_mxid
):
# allow the server notices mxid to create rooms
is_requester_admin = True
else:
is_requester_admin = await self.auth.is_server_admin(requester.user)
if not is_requester_admin and not await self.spam_checker.user_may_create_room(
user_id, invite_list=[], third_party_invite_list=[], cloning=True
):
if not await self.spam_checker.user_may_create_room(user_id):
raise SynapseError(403, "You are not permitted to create rooms")
creation_content: JsonDict = {
@@ -636,14 +624,16 @@ class RoomCreationHandler(BaseHandler):
requester, config, is_requester_admin=is_requester_admin
)
invite_list = config.get("invite", [])
invite_3pid_list = config.get("invite_3pid", [])
invite_list = config.get("invite", [])
if not is_requester_admin and not await self.spam_checker.user_may_create_room(
user_id,
invite_list=invite_list,
third_party_invite_list=invite_3pid_list,
cloning=False,
if not is_requester_admin and not (
await self.spam_checker.user_may_create_room(user_id)
and await self.spam_checker.user_may_create_room_with_invites(
user_id,
invite_list,
invite_3pid_list,
)
):
raise SynapseError(403, "You are not permitted to create rooms")
@@ -677,8 +667,6 @@ class RoomCreationHandler(BaseHandler):
if mapping:
raise SynapseError(400, "Room alias already taken", Codes.ROOM_IN_USE)
invite_3pid_list = config.get("invite_3pid", [])
invite_list = config.get("invite", [])
for i in invite_list:
try:
uid = UserID.from_string(i)
@@ -861,7 +849,6 @@ class RoomCreationHandler(BaseHandler):
id_server,
requester,
txn_id=None,
new_room=True,
id_access_token=id_access_token,
)

View File

@@ -449,6 +449,8 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
third_party_signed: Information from a 3PID invite.
ratelimit: Whether to rate limit the request.
content: The content of the created event.
new_room: Whether the membership update is happening in the context of a room
creation.
require_consent: Whether consent is required.
outlier: Indicates whether the event is an `outlier`, i.e. if
it's from an arbitrary point and floating in the DAG as
@@ -523,6 +525,8 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
third_party_signed:
ratelimit:
content:
new_room: Whether the membership update is happening in the context of a room
creation.
require_consent:
outlier: Indicates whether the event is an `outlier`, i.e. if
it's from an arbitrary point and floating in the DAG as
@@ -610,15 +614,8 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
)
block_invite = True
is_published = await self.store.is_room_published(room_id)
if not await self.spam_checker.user_may_invite(
requester.user.to_string(),
target_id,
third_party_invite=None,
room_id=room_id,
new_room=new_room,
published_room=is_published,
requester.user.to_string(), target_id, room_id
):
logger.info("Blocking invite due to spam checker")
block_invite = True
@@ -711,24 +708,29 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
# so don't really fit into the general auth process.
raise AuthError(403, "Guest access not allowed")
# Figure out whether the user is a server admin to determine whether they
# should be able to bypass the spam checker.
if (
self._server_notices_mxid is not None
and requester.user.to_string() == self._server_notices_mxid
):
# allow the server notices mxid to join rooms
is_requester_admin = True
bypass_spam_checker = True
else:
is_requester_admin = await self.auth.is_server_admin(requester.user)
bypass_spam_checker = await self.auth.is_server_admin(requester.user)
inviter = await self._get_inviter(target.to_string(), room_id)
if not is_requester_admin:
if (
not bypass_spam_checker
# We assume that if the spam checker allowed the user to create
# a room then they're allowed to join it.
if not new_room and not await self.spam_checker.user_may_join_room(
and not new_room
and not await self.spam_checker.user_may_join_room(
target.to_string(), room_id, is_invited=inviter is not None
):
raise SynapseError(403, "Not allowed to join this room")
)
):
raise SynapseError(403, "Not allowed to join this room")
# Check if a remote join should be performed.
remote_join, remote_room_hosts = await self._should_perform_remote_join(
@@ -1161,7 +1163,6 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
id_server: str,
requester: Requester,
txn_id: Optional[str],
new_room: bool = False,
id_access_token: Optional[str] = None,
) -> int:
"""Invite a 3PID to a room.
@@ -1228,26 +1229,25 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
id_server, medium, address, id_access_token
)
is_published = await self.store.is_room_published(room_id)
if not await self.spam_checker.user_may_invite(
requester.user.to_string(),
invitee,
third_party_invite={"medium": medium, "address": address},
room_id=room_id,
new_room=new_room,
published_room=is_published,
):
logger.info("Blocking invite due to spam checker")
raise SynapseError(403, "Invites have been disabled on this server")
if invitee:
# Note that update_membership with an action of "invite" can raise
# a ShadowBanError, but this was done above already.
# We don't check the invite against the spamchecker(s) here (through
# user_may_invite) because we'll do it further down the line anyway (in
# update_membership_locked).
_, stream_id = await self.update_membership(
requester, UserID.from_string(invitee), room_id, "invite", txn_id=txn_id
)
else:
# Check if the spamchecker(s) allow this invite to go through.
if not await self.spam_checker.user_may_send_3pid_invite(
inviter_userid=requester.user.to_string(),
medium=medium,
address=address,
room_id=room_id,
):
raise SynapseError(403, "Cannot send threepid invite")
stream_id = await self._make_and_store_3pid_invite(
requester,
id_server,

View File

@@ -45,7 +45,14 @@ from synapse.metrics.background_process_metrics import run_as_background_process
from synapse.storage.database import DatabasePool, LoggingTransaction
from synapse.storage.databases.main.roommember import ProfileInfo
from synapse.storage.state import StateFilter
from synapse.types import JsonDict, Requester, UserID, UserInfo, create_requester
from synapse.types import (
JsonDict,
Requester,
StateMap,
UserID,
UserInfo,
create_requester,
)
from synapse.util import Clock
from synapse.util.caches.descriptors import cached
@@ -70,6 +77,8 @@ __all__ = [
"DirectServeHtmlResource",
"DirectServeJsonResource",
"ModuleApi",
"EventBase",
"StateMap",
]
logger = logging.getLogger(__name__)
@@ -690,6 +699,52 @@ class ModuleApi:
(td for td in (self.custom_template_dir, custom_template_directory) if td),
)
async def get_room_state(
self,
room_id: str,
event_filter: Optional[Iterable[Tuple[str, Optional[str]]]] = None,
) -> StateMap[EventBase]:
"""Returns the current state of the given room.
The events are returned as a mapping, in which the key for each event is a tuple
which first element is the event's type and the second one is its state key.
Added in Synapse v1.47.0
Args:
room_id: The ID of the room to get state from.
event_filter: A filter to apply when retrieving events. None if no filter
should be applied. If provided, must be an iterable of tuples. A tuple's
first element is the event type and the second is the state key, or is
None if the state key should not be filtered on.
An example of a filter is:
[
("m.room.member", "@alice:example.com"), # Member event for @alice:example.com
("org.matrix.some_event", ""), # State event of type "org.matrix.some_event"
# with an empty string as its state key
("org.matrix.some_other_event", None), # State events of type "org.matrix.some_other_event"
# regardless of their state key
]
"""
if event_filter:
# If a filter was provided, turn it into a StateFilter and retrieve a filtered
# view of the state.
state_filter = StateFilter.from_types(event_filter)
state_ids = await self._store.get_filtered_current_state_ids(
room_id,
state_filter,
)
else:
# If no filter was provided, get the whole state. We could also reuse the call
# to get_filtered_current_state_ids above, with `state_filter = StateFilter.all()`,
# but get_filtered_current_state_ids isn't cached and `get_current_state_ids`
# is, so using the latter when we can is better for perf.
state_ids = await self._store.get_current_state_ids(room_id)
state_events = await self._store.get_events(state_ids.values())
return {key: state_events[event_id] for key, event_id in state_ids.items()}
class PublicRoomListManager:
"""Contains methods for adding to, removing from and querying whether a room

View File

@@ -734,7 +734,6 @@ class RoomMembershipRestServlet(TransactionRestServlet):
content["id_server"],
requester,
txn_id,
new_room=False,
id_access_token=content.get("id_access_token"),
)
except ShadowBanError:

View File

@@ -14,8 +14,11 @@
# limitations under the License.
import logging
from typing import Optional
from synapse.api.constants import EventTypes, Membership
from synapse.config._base import ConfigError
from synapse.module_api import ModuleApi
logger = logging.getLogger(__name__)
@@ -56,16 +59,13 @@ class DomainRuleChecker(object):
Don't forget to consider if you can invite users from your own domain.
"""
def __init__(self, config):
def __init__(self, config, api: ModuleApi):
self.domain_mapping = config["domain_mapping"] or {}
self.default = config["default"]
self.can_only_join_rooms_with_invite = config.get(
"can_only_join_rooms_with_invite", False
)
self.can_only_create_one_to_one_rooms = config.get(
"can_only_create_one_to_one_rooms", False
)
self.can_only_invite_during_room_creation = config.get(
"can_only_invite_during_room_creation", False
)
@@ -76,30 +76,104 @@ class DomainRuleChecker(object):
"domains_prevented_from_being_invited_to_published_rooms", []
)
def check_event_for_spam(self, event):
"""Implements synapse.events.SpamChecker.check_event_for_spam"""
return False
self._api = api
def user_may_invite(
self._api.register_spam_checker_callbacks(
user_may_invite=self.user_may_invite,
user_may_send_3pid_invite=self.user_may_send_3pid_invite,
user_may_join_room=self.user_may_join_room,
)
async def _is_new_room(self, room_id: str) -> bool:
"""Checks if the room provided looks new according to its state.
The module will consider a room to look new if the only m.room.member events in
its state are either for the room's creator (i.e. its join event) or invites sent
by the room's creator.
Args:
room_id: The ID of the room to check.
Returns:
Whether the room looks new.
"""
state_event_filter = [
(EventTypes.Create, None),
(EventTypes.Member, None),
]
events = await self._api.get_room_state(room_id, state_event_filter)
room_creator = events[(EventTypes.Create, "")].sender
for key, event in events.items():
if key[0] == EventTypes.Create:
continue
if key[1] != room_creator:
if (
event.sender != room_creator
and event.membership != Membership.INVITE
):
return False
return True
async def user_may_invite(
self,
inviter_userid,
invitee_userid,
third_party_invite,
room_id,
new_room,
published_room=False,
):
"""Implements synapse.events.SpamChecker.user_may_invite"""
inviter_userid: str,
invitee_userid: str,
room_id: str,
) -> bool:
"""Implements the user_may_invite spam checker callback."""
return await self._user_may_invite(
room_id=room_id,
inviter_userid=inviter_userid,
invitee_userid=invitee_userid,
)
async def user_may_send_3pid_invite(
self,
inviter_userid: str,
medium: str,
address: str,
room_id: str,
) -> bool:
"""Implements the user_may_send_3pid_invite spam checker callback."""
return await self._user_may_invite(
room_id=room_id,
inviter_userid=inviter_userid,
invitee_userid=None,
)
async def _user_may_invite(
self,
room_id: str,
inviter_userid: str,
invitee_userid: Optional[str],
) -> bool:
"""Processes any incoming invite, both normal Matrix invites and 3PID ones, and
check if they should be allowed.
Args:
room_id: The ID of the room the invite is happening in.
inviter_userid: The MXID of the user sending the invite.
invitee_userid: The MXID of the user being invited, or None if this is a 3PID
invite (in which case no MXID exists for this user yet).
Returns:
Whether the invite can be allowed to go through.
"""
new_room = await self._is_new_room(room_id)
if self.can_only_invite_during_room_creation and not new_room:
return False
if not self.can_invite_by_third_party_id and third_party_invite:
return False
# This is a third party invite (without a bound mxid), so unless we have
# banned all third party invites (above) we allow it.
if not invitee_userid:
return True
# If invitee_userid is None, then this means this is a 3PID invite (without a
# bound MXID), so we allow it unless the configuration mandates blocking all 3PID
# invites.
if invitee_userid is None:
return self.can_invite_by_third_party_id
inviter_domain = self._get_domain_from_id(inviter_userid)
invitee_domain = self._get_domain_from_id(invitee_userid)
@@ -107,6 +181,12 @@ class DomainRuleChecker(object):
if inviter_domain not in self.domain_mapping:
return self.default
published_room = (
await self._api.public_room_list_manager.room_is_in_public_room_list(
room_id
)
)
if (
published_room
and invitee_domain
@@ -116,34 +196,8 @@ class DomainRuleChecker(object):
return invitee_domain in self.domain_mapping[inviter_domain]
def user_may_create_room(
self, userid, invite_list, third_party_invite_list, cloning
):
"""Implements synapse.events.SpamChecker.user_may_create_room"""
if cloning:
return True
if not self.can_invite_by_third_party_id and third_party_invite_list:
return False
number_of_invites = len(invite_list) + len(third_party_invite_list)
if self.can_only_create_one_to_one_rooms and number_of_invites != 1:
return False
return True
def user_may_create_room_alias(self, userid, room_alias):
"""Implements synapse.events.SpamChecker.user_may_create_room_alias"""
return True
def user_may_publish_room(self, userid, room_id):
"""Implements synapse.events.SpamChecker.user_may_publish_room"""
return True
def user_may_join_room(self, userid, room_id, is_invited):
"""Implements synapse.events.SpamChecker.user_may_join_room"""
async def user_may_join_room(self, userid, room_id, is_invited):
"""Implements the user_may_join_room spam checker callback."""
if self.can_only_join_rooms_with_invite and not is_invited:
return False
@@ -151,7 +205,9 @@ class DomainRuleChecker(object):
@staticmethod
def parse_config(config):
"""Implements synapse.events.SpamChecker.parse_config"""
"""Checks whether required fields exist in the provided configuration for the
module.
"""
if "default" in config:
return config
else:

View File

@@ -15,7 +15,7 @@ from unittest.mock import Mock
from twisted.internet import defer
from synapse.api.constants import EduTypes
from synapse.api.constants import EduTypes, EventTypes
from synapse.events import EventBase
from synapse.federation.units import Transaction
from synapse.handlers.presence import UserPresenceState
@@ -308,6 +308,29 @@ class ModuleApiTestCase(HomeserverTestCase):
self.assertTrue(found_update)
def test_get_room_state(self):
"""Tests that a module can retrieve the state of a room through the module API."""
user_id = self.register_user("peter", "hackme")
tok = self.login("peter", "hackme")
# Create a room and send some custom state in it.
room_id = self.helper.create_room_as(tok=tok)
self.helper.send_state(room_id, "org.matrix.test", {}, tok=tok)
# Check that the module API can successfully fetch state for the room.
state = self.get_success(
defer.ensureDeferred(self.module_api.get_room_state(room_id))
)
# Check that a few standard events are in the returned state.
self.assertIn((EventTypes.Create, ""), state)
self.assertIn((EventTypes.Member, user_id), state)
# Check that our custom state event is in the returned state.
self.assertEqual(state[("org.matrix.test", "")].sender, user_id)
self.assertEqual(state[("org.matrix.test", "")].state_key, "")
self.assertEqual(state[("org.matrix.test", "")].content, {})
class ModuleApiWorkerTestCase(BaseMultiWorkerStreamTestCase):
"""For testing ModuleApi functionality in a multi-worker setup"""

View File

@@ -18,7 +18,7 @@
"""Tests REST events for /rooms paths."""
import json
from typing import Iterable
from typing import Dict, Iterable, List, Optional
from unittest.mock import Mock, call
from urllib import parse as urlparse
@@ -30,7 +30,7 @@ from synapse.api.errors import HttpResponseException
from synapse.handlers.pagination import PurgeStatus
from synapse.rest import admin
from synapse.rest.client import account, directory, login, profile, room, sync
from synapse.types import JsonDict, RoomAlias, UserID, create_requester
from synapse.types import JsonDict, Requester, RoomAlias, UserID, create_requester
from synapse.util.stringutils import random_string
from tests import unittest
@@ -584,6 +584,145 @@ class RoomsCreateTestCase(RoomBase):
channel = self.make_request("POST", "/createRoom", content)
self.assertEqual(200, channel.code)
def test_spamchecker_invites(self):
"""Tests the user_may_create_room_with_invites spam checker callback."""
# Mock do_3pid_invite, so we don't fail from failing to send a 3PID invite to an
# IS.
async def do_3pid_invite(
room_id: str,
inviter: UserID,
medium: str,
address: str,
id_server: str,
requester: Requester,
txn_id: Optional[str],
id_access_token: Optional[str] = None,
) -> int:
return 0
do_3pid_invite_mock = Mock(side_effect=do_3pid_invite)
self.hs.get_room_member_handler().do_3pid_invite = do_3pid_invite_mock
# Add a mock callback for user_may_create_room_with_invites. Make it allow any
# room creation request for now.
return_value = True
async def user_may_create_room_with_invites(
user: str,
invites: List[str],
threepid_invites: List[Dict[str, str]],
) -> bool:
return return_value
callback_mock = Mock(side_effect=user_may_create_room_with_invites)
self.hs.get_spam_checker()._user_may_create_room_with_invites_callbacks.append(
callback_mock,
)
# The MXIDs we'll try to invite.
invited_mxids = [
"@alice1:red",
"@alice2:red",
"@alice3:red",
"@alice4:red",
]
# The 3PIDs we'll try to invite.
invited_3pids = [
{
"id_server": "example.com",
"id_access_token": "sometoken",
"medium": "email",
"address": "alice1@example.com",
},
{
"id_server": "example.com",
"id_access_token": "sometoken",
"medium": "email",
"address": "alice2@example.com",
},
{
"id_server": "example.com",
"id_access_token": "sometoken",
"medium": "email",
"address": "alice3@example.com",
},
]
# Create a room and invite the Matrix users, and check that it succeeded.
channel = self.make_request(
"POST",
"/createRoom",
json.dumps({"invite": invited_mxids}).encode("utf8"),
)
self.assertEqual(200, channel.code)
# Check that the callback was called with the right arguments.
expected_call_args = ((self.user_id, invited_mxids, []),)
self.assertEquals(
callback_mock.call_args,
expected_call_args,
callback_mock.call_args,
)
# Create a room and invite the 3PIDs, and check that it succeeded.
channel = self.make_request(
"POST",
"/createRoom",
json.dumps({"invite_3pid": invited_3pids}).encode("utf8"),
)
self.assertEqual(200, channel.code)
# Check that do_3pid_invite was called the right amount of time
self.assertEquals(do_3pid_invite_mock.call_count, len(invited_3pids))
# Check that the callback was called with the right arguments.
expected_call_args = ((self.user_id, [], invited_3pids),)
self.assertEquals(
callback_mock.call_args,
expected_call_args,
callback_mock.call_args,
)
# Now deny any room creation.
return_value = False
# Create a room and invite the 3PIDs, and check that it failed.
channel = self.make_request(
"POST",
"/createRoom",
json.dumps({"invite_3pid": invited_3pids}).encode("utf8"),
)
self.assertEqual(403, channel.code)
# Check that do_3pid_invite wasn't called this time.
self.assertEquals(do_3pid_invite_mock.call_count, len(invited_3pids))
def test_spam_checker_may_join_room(self):
"""Tests that the user_may_join_room spam checker callback is correctly bypassed
when creating a new room.
"""
async def user_may_join_room(
mxid: str,
room_id: str,
is_invite: bool,
) -> bool:
return False
join_mock = Mock(side_effect=user_may_join_room)
self.hs.get_spam_checker()._user_may_join_room_callbacks.append(join_mock)
channel = self.make_request(
"POST",
"/createRoom",
{},
)
self.assertEquals(channel.code, 200, channel.json_body)
self.assertEquals(join_mock.call_count, 0)
class RoomTopicTestCase(RoomBase):
"""Tests /rooms/$room_id/topic REST events."""
@@ -775,6 +914,83 @@ class RoomInviteRatelimitTestCase(RoomBase):
self.helper.invite(room_id, self.user_id, "@other-users:red", expect_code=429)
class RoomJoinTestCase(RoomBase):
servlets = [
admin.register_servlets,
login.register_servlets,
room.register_servlets,
]
def prepare(self, reactor, clock, homeserver):
self.user1 = self.register_user("thomas", "hackme")
self.tok1 = self.login("thomas", "hackme")
self.user2 = self.register_user("teresa", "hackme")
self.tok2 = self.login("teresa", "hackme")
self.room1 = self.helper.create_room_as(room_creator=self.user1, tok=self.tok1)
self.room2 = self.helper.create_room_as(room_creator=self.user1, tok=self.tok1)
self.room3 = self.helper.create_room_as(room_creator=self.user1, tok=self.tok1)
def test_spam_checker_may_join_room(self):
"""Tests that the user_may_join_room spam checker callback is correctly called
and blocks room joins when needed.
"""
# Register a dummy callback. Make it allow all room joins for now.
return_value = True
async def user_may_join_room(
userid: str,
room_id: str,
is_invited: bool,
) -> bool:
return return_value
callback_mock = Mock(side_effect=user_may_join_room)
self.hs.get_spam_checker()._user_may_join_room_callbacks.append(callback_mock)
# Join a first room, without being invited to it.
self.helper.join(self.room1, self.user2, tok=self.tok2)
# Check that the callback was called with the right arguments.
expected_call_args = (
(
self.user2,
self.room1,
False,
),
)
self.assertEquals(
callback_mock.call_args,
expected_call_args,
callback_mock.call_args,
)
# Join a second room, this time with an invite for it.
self.helper.invite(self.room2, self.user1, self.user2, tok=self.tok1)
self.helper.join(self.room2, self.user2, tok=self.tok2)
# Check that the callback was called with the right arguments.
expected_call_args = (
(
self.user2,
self.room2,
True,
),
)
self.assertEquals(
callback_mock.call_args,
expected_call_args,
callback_mock.call_args,
)
# Now make the callback deny all room joins, and check that a join actually fails.
return_value = False
self.helper.join(self.room3, self.user2, expect_code=403, tok=self.tok2)
class RoomJoinRatelimitTestCase(RoomBase):
user_id = "@sid1:red"
@@ -2230,3 +2446,73 @@ class RoomCanonicalAliasTestCase(unittest.HomeserverTestCase):
"""An alias which does not point to the room raises a SynapseError."""
self._set_canonical_alias({"alias": "@unknown:test"}, expected_code=400)
self._set_canonical_alias({"alt_aliases": ["@unknown:test"]}, expected_code=400)
class ThreepidInviteTestCase(unittest.HomeserverTestCase):
servlets = [
admin.register_servlets,
login.register_servlets,
room.register_servlets,
]
def prepare(self, reactor, clock, homeserver):
self.user_id = self.register_user("thomas", "hackme")
self.tok = self.login("thomas", "hackme")
self.room_id = self.helper.create_room_as(self.user_id, tok=self.tok)
def test_threepid_invite_spamcheck(self):
# Mock a few functions to prevent the test from failing due to failing to talk to
# a remote IS. We keep the mock for _mock_make_and_store_3pid_invite around so we
# can check its call_count later on during the test.
make_invite_mock = Mock(return_value=make_awaitable(0))
self.hs.get_room_member_handler()._make_and_store_3pid_invite = make_invite_mock
self.hs.get_identity_handler().lookup_3pid = Mock(
return_value=make_awaitable(None),
)
# Add a mock to the spamchecker callbacks for user_may_send_3pid_invite. Make it
# allow everything for now.
mock = Mock(return_value=make_awaitable(True))
self.hs.get_spam_checker()._user_may_send_3pid_invite_callbacks.append(mock)
# Send a 3PID invite into the room and check that it succeeded.
email_to_invite = "teresa@example.com"
channel = self.make_request(
method="POST",
path="/rooms/" + self.room_id + "/invite",
content={
"id_server": "example.com",
"id_access_token": "sometoken",
"medium": "email",
"address": email_to_invite,
},
access_token=self.tok,
)
self.assertEquals(channel.code, 200)
# Check that the callback was called with the right params.
mock.assert_called_with(self.user_id, "email", email_to_invite, self.room_id)
# Check that the call to send the invite was made.
make_invite_mock.assert_called_once()
# Now change the return value of the callback to deny any invite and test that
# we can't send the invite.
mock.return_value = make_awaitable(False)
channel = self.make_request(
method="POST",
path="/rooms/" + self.room_id + "/invite",
content={
"id_server": "example.com",
"id_access_token": "sometoken",
"medium": "email",
"address": email_to_invite,
},
access_token=self.tok,
)
self.assertEquals(channel.code, 403)
# Also check that it stopped before calling _make_and_store_3pid_invite.
make_invite_mock.assert_called_once()

View File

@@ -15,17 +15,93 @@
import json
from typing import Optional
import attr
import synapse.rest.admin
from synapse.api.constants import EventTypes
from synapse.config._base import ConfigError
from synapse.events.spamcheck import load_legacy_spam_checkers
from synapse.rest.client import login, room
from synapse.rulecheck.domain_rule_checker import DomainRuleChecker
from tests import unittest
class DomainRuleCheckerTestCase(unittest.TestCase):
@attr.s(auto_attribs=True)
class MockEvent:
"""Mock of an event, only implementing the fields the DomainRuleChecker module will
use.
"""
sender: str
membership: Optional[str] = None
@attr.s(auto_attribs=True)
class MockPublicRoomListManager:
"""Mock of a synapse.module_api.PublicRoomListManager, only implementing the method
the DomainRuleChecker module will use.
"""
_published: bool
async def room_is_in_public_room_list(self, room_id: str) -> bool:
return self._published
@attr.s(auto_attribs=True)
class MockModuleApi:
"""Mock of a synapse.module_api.ModuleApi, only implementing the methods the
DomainRuleChecker module will use.
"""
_new_room: bool
_published: bool
def register_spam_checker_callbacks(self, *args, **kwargs):
"""Don't fail when the module tries to register its callbacks."""
pass
@property
def public_room_list_manager(self):
"""Returns a mock public room list manager. We could in theory return a Mock with
a return value of make_awaitable(self._published), but local testing seems to show
this doesn't work on all versions of Python.
"""
return MockPublicRoomListManager(self._published)
async def get_room_state(self, *args, **kwargs):
"""Mocks the ModuleApi's get_room_state method, by returning mock events. The
number of events depends on whether we're testing for a new room or not (if the
room is not new it will have an extra user joined to it).
"""
state = {
(EventTypes.Create, ""): MockEvent("room_creator"),
(EventTypes.Member, "room_creator"): MockEvent("room_creator", "join"),
(EventTypes.Member, "invitee"): MockEvent("room_creator", "invite"),
}
if not self._new_room:
state[(EventTypes.Member, "joinee")] = MockEvent("joinee", "join")
return state
# We use a HomeserverTestCase despite not using the homeserver itself because we need a
# reactor to run asynchronous code.
class DomainRuleCheckerTestCase(unittest.HomeserverTestCase):
def _test_user_may_invite(
self,
config,
inviter,
invitee,
new_room,
published,
) -> bool:
check = DomainRuleChecker(config, MockModuleApi(new_room, published))
return self.get_success(check.user_may_invite(inviter, invitee, "room"))
def test_allowed(self):
config = {
"default": False,
@@ -35,35 +111,57 @@ class DomainRuleCheckerTestCase(unittest.TestCase):
},
"domains_prevented_from_being_invited_to_published_rooms": ["target_two"],
}
check = DomainRuleChecker(config)
self.assertTrue(
check.user_may_invite(
"test:source_one", "test:target_one", None, "room", False
)
self._test_user_may_invite(
config,
"test:source_one",
"test:target_one",
False,
False,
),
)
self.assertTrue(
check.user_may_invite(
"test:source_one", "test:target_two", None, "room", False
)
self._test_user_may_invite(
config,
"test:source_one",
"test:target_two",
False,
False,
),
)
self.assertTrue(
check.user_may_invite(
"test:source_two", "test:target_two", None, "room", False
)
self._test_user_may_invite(
config,
"test:source_two",
"test:target_two",
False,
False,
),
)
# User can invite internal user to a published room
self.assertTrue(
check.user_may_invite(
"test:source_one", "test1:target_one", None, "room", False, True
)
self._test_user_may_invite(
config,
"test:source_one",
"test1:target_one",
False,
True,
),
)
# User can invite external user to a non-published room
self.assertTrue(
check.user_may_invite(
"test:source_one", "test:target_two", None, "room", False, False
)
self._test_user_may_invite(
config,
"test:source_one",
"test:target_two",
False,
False,
),
)
def test_disallowed(self):
@@ -75,32 +173,40 @@ class DomainRuleCheckerTestCase(unittest.TestCase):
"source_four": [],
},
}
check = DomainRuleChecker(config)
self.assertFalse(
check.user_may_invite(
"test:source_one", "test:target_three", None, "room", False
self._test_user_may_invite(
config,
"test:source_one",
"test:target_three",
False,
False,
)
)
self.assertFalse(
check.user_may_invite(
"test:source_two", "test:target_three", None, "room", False
self._test_user_may_invite(
config,
"test:source_two",
"test:target_three",
False,
False,
)
)
self.assertFalse(
check.user_may_invite(
"test:source_two", "test:target_one", None, "room", False
self._test_user_may_invite(
config, "test:source_two", "test:target_one", False, False
)
)
self.assertFalse(
check.user_may_invite(
"test:source_four", "test:target_one", None, "room", False
self._test_user_may_invite(
config, "test:source_four", "test:target_one", False, False
)
)
# User cannot invite external user to a published room
self.assertTrue(
check.user_may_invite(
"test:source_one", "test:target_two", None, "room", False, True
self._test_user_may_invite(
config, "test:source_one", "test:target_two", False, True
)
)
@@ -112,10 +218,14 @@ class DomainRuleCheckerTestCase(unittest.TestCase):
"source_two": ["target_two"],
},
}
check = DomainRuleChecker(config)
self.assertTrue(
check.user_may_invite(
"test:source_three", "test:target_one", None, "room", False
self._test_user_may_invite(
config,
"test:source_three",
"test:target_one",
False,
False,
)
)
@@ -127,10 +237,14 @@ class DomainRuleCheckerTestCase(unittest.TestCase):
"source_two": ["target_two"],
},
}
check = DomainRuleChecker(config)
self.assertFalse(
check.user_may_invite(
"test:source_three", "test:target_one", None, "room", False
self._test_user_may_invite(
config,
"test:source_three",
"test:target_one",
False,
False,
)
)
@@ -167,20 +281,26 @@ class DomainRuleCheckerRoomTestCase(unittest.HomeserverTestCase):
config = self.default_config()
config["trusted_third_party_id_servers"] = ["localhost"]
config["spam_checker"] = {
"module": "synapse.rulecheck.domain_rule_checker.DomainRuleChecker",
"config": {
"default": True,
"domain_mapping": {},
"can_only_join_rooms_with_invite": True,
"can_only_create_one_to_one_rooms": True,
"can_only_invite_during_room_creation": True,
"can_invite_by_third_party_id": False,
},
}
config["modules"] = [
{
"module": "synapse.rulecheck.domain_rule_checker.DomainRuleChecker",
"config": {
"default": True,
"domain_mapping": {},
"can_only_join_rooms_with_invite": True,
"can_only_create_one_to_one_rooms": True,
"can_only_invite_during_room_creation": True,
"can_invite_by_third_party_id": False,
},
}
]
hs = self.setup_test_homeserver(config=config)
load_legacy_spam_checkers(hs)
module_api = hs.get_module_api()
for module, config in hs.config.modules.loaded_modules:
module(config=config, api=module_api)
return hs
def prepare(self, reactor, clock, hs):
@@ -196,43 +316,6 @@ class DomainRuleCheckerRoomTestCase(unittest.HomeserverTestCase):
channel = self._create_room(self.admin_access_token)
assert channel.result["code"] == b"200", channel.result
def test_normal_user_cannot_create_empty_room(self):
channel = self._create_room(self.normal_access_token)
assert channel.result["code"] == b"403", channel.result
def test_normal_user_cannot_create_room_with_multiple_invites(self):
channel = self._create_room(
self.normal_access_token,
content={"invite": [self.other_user_id, self.admin_user_id]},
)
assert channel.result["code"] == b"403", channel.result
# Test that it correctly counts both normal and third party invites
channel = self._create_room(
self.normal_access_token,
content={
"invite": [self.other_user_id],
"invite_3pid": [{"medium": "email", "address": "foo@example.com"}],
},
)
assert channel.result["code"] == b"403", channel.result
# Test that it correctly rejects third party invites
channel = self._create_room(
self.normal_access_token,
content={
"invite": [],
"invite_3pid": [{"medium": "email", "address": "foo@example.com"}],
},
)
assert channel.result["code"] == b"403", channel.result
def test_normal_user_can_room_with_single_invites(self):
channel = self._create_room(
self.normal_access_token, content={"invite": [self.other_user_id]}
)
assert channel.result["code"] == b"200", channel.result
def test_cannot_join_public_room(self):
channel = self._create_room(self.admin_access_token)
assert channel.result["code"] == b"200", channel.result