diff --git a/changelog.d/10894.feature b/changelog.d/10894.feature new file mode 100644 index 0000000000..a4f968bed1 --- /dev/null +++ b/changelog.d/10894.feature @@ -0,0 +1 @@ +Add a `user_may_send_3pid_invite` spam checker callback for modules to allow or deny 3PID invites. diff --git a/changelog.d/10898.feature b/changelog.d/10898.feature new file mode 100644 index 0000000000..97fa39fd0c --- /dev/null +++ b/changelog.d/10898.feature @@ -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. diff --git a/changelog.d/10910.feature b/changelog.d/10910.feature new file mode 100644 index 0000000000..aee139f8b6 --- /dev/null +++ b/changelog.d/10910.feature @@ -0,0 +1 @@ +Add a spam checker callback to allow or deny room joins. diff --git a/changelog.d/11204.feature b/changelog.d/11204.feature new file mode 100644 index 0000000000..f58ed4b3dc --- /dev/null +++ b/changelog.d/11204.feature @@ -0,0 +1 @@ +Add a module API method to retrieve the current state of a room. diff --git a/docs/modules/spam_checker_callbacks.md b/docs/modules/spam_checker_callbacks.md new file mode 100644 index 0000000000..787e99074a --- /dev/null +++ b/docs/modules/spam_checker_callbacks.md @@ -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 +``` diff --git a/synapse/events/spamcheck.py b/synapse/events/spamcheck.py index 67d66b5a1f..3134beb8d3 100644 --- a/synapse/events/spamcheck.py +++ b/synapse/events/spamcheck.py @@ -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. diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 5f5ee1d300..110b6bdf74 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -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" diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index 8ee88b6b80..5030542aff 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -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, ) diff --git a/synapse/handlers/room_member.py b/synapse/handlers/room_member.py index 362cd0cdde..9655d7f0a7 100644 --- a/synapse/handlers/room_member.py +++ b/synapse/handlers/room_member.py @@ -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, diff --git a/synapse/module_api/__init__.py b/synapse/module_api/__init__.py index 2d2ed229e2..739939b6ef 100644 --- a/synapse/module_api/__init__.py +++ b/synapse/module_api/__init__.py @@ -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 diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py index 98c3a5e334..bd6885e5dc 100644 --- a/synapse/rest/client/room.py +++ b/synapse/rest/client/room.py @@ -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: diff --git a/synapse/rulecheck/domain_rule_checker.py b/synapse/rulecheck/domain_rule_checker.py index 11e7cb59da..b4d659b06a 100644 --- a/synapse/rulecheck/domain_rule_checker.py +++ b/synapse/rulecheck/domain_rule_checker.py @@ -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: diff --git a/tests/module_api/test_api.py b/tests/module_api/test_api.py index 7dd519cd44..9904e1bd17 100644 --- a/tests/module_api/test_api.py +++ b/tests/module_api/test_api.py @@ -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""" diff --git a/tests/rest/client/v1/test_rooms.py b/tests/rest/client/v1/test_rooms.py index 50100a5ae4..b726310b4d 100644 --- a/tests/rest/client/v1/test_rooms.py +++ b/tests/rest/client/v1/test_rooms.py @@ -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() diff --git a/tests/rulecheck/test_domainrulecheck.py b/tests/rulecheck/test_domainrulecheck.py index eee980c9a6..ab4023a72f 100644 --- a/tests/rulecheck/test_domainrulecheck.py +++ b/tests/rulecheck/test_domainrulecheck.py @@ -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