Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Add a spamchecker method to allow or deny 3pid invites #10894

Merged
merged 10 commits into from
Oct 6, 2021
1 change: 1 addition & 0 deletions changelog.d/10894.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a `user_may_send_3pid_invite` spam checker callback for modules to allow or deny 3PID invites.
35 changes: 35 additions & 0 deletions docs/modules/spam_checker_callbacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,41 @@ Called when processing an invitation. The module must return a `bool` indicating
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_threepid_invite`

```python
async def user_may_send_3pid_invite(
inviter: str,
invitee: Dict[str, 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 a dict describing the third-party identifier to send an
invitation to, with a `medium` key indicating the identifier's medium (e.g. "email") and
an `address` key indicating the identifier's 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
{"medium": "email", "address": "alice@example.com"}, # 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.
babolivier marked this conversation as resolved.
Show resolved Hide resolved

### `user_may_create_room`

```python
Expand Down
41 changes: 41 additions & 0 deletions synapse/events/spamcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@
Awaitable[Union[bool, str]],
]
USER_MAY_INVITE_CALLBACK = Callable[[str, str, str], Awaitable[bool]]
USER_MAY_SEND_3PID_INVITE_CALLBACK = Callable[
[str, Dict[str, str], str],
Awaitable[bool]
]
USER_MAY_CREATE_ROOM_CALLBACK = Callable[[str], Awaitable[bool]]
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK = Callable[[str, RoomAlias], Awaitable[bool]]
USER_MAY_PUBLISH_ROOM_CALLBACK = Callable[[str, str], Awaitable[bool]]
Expand Down Expand Up @@ -163,6 +167,9 @@ class SpamChecker:
def __init__(self):
self._check_event_for_spam_callbacks: List[CHECK_EVENT_FOR_SPAM_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_alias_callbacks: List[
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK
Expand All @@ -182,6 +189,9 @@ def register_callbacks(
self,
check_event_for_spam: Optional[CHECK_EVENT_FOR_SPAM_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_alias: Optional[
USER_MAY_CREATE_ROOM_ALIAS_CALLBACK
Expand All @@ -200,6 +210,11 @@ def register_callbacks(
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)

Expand Down Expand Up @@ -266,6 +281,32 @@ async def user_may_invite(

return True

async def user_may_send_3pid_invite(
self, inviter_userid: str, invitee_threepid: Dict[str, 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
invitee_threepid: The threepid targeted in the invitation, 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")
babolivier marked this conversation as resolved.
Show resolved Hide resolved
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, invitee_threepid, 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

Expand Down
11 changes: 11 additions & 0 deletions synapse/handlers/room_member.py
Original file line number Diff line number Diff line change
Expand Up @@ -1260,10 +1260,21 @@ async def do_3pid_invite(
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).
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved
_, 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(),
invitee_threepid={"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,
Expand Down
137 changes: 135 additions & 2 deletions tests/rest/client/test_rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"""Tests REST events for /rooms paths."""

import json
from typing import Iterable
from typing import Iterable, Optional, Dict
from unittest.mock import Mock, call
from urllib import parse as urlparse

Expand All @@ -30,7 +30,7 @@
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, RoomAlias, UserID, create_requester, Requester
from synapse.util.stringutils import random_string

from tests import unittest
Expand Down Expand Up @@ -2315,3 +2315,136 @@ def test_bad_alias(self):
"""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 = self._mock_make_and_store_3pid_invite()
self._mock_lookup_3pid()

# Add a mock to the spamchecker callbacks for user_may_send_3pid_invite. Make it
# allow everything for now.
return_value = True

async def _user_may_send_3pid_invite(
inviter: str,
invitee: Dict[str, str],
room_id: str,
) -> bool:
return return_value

allow_mock = Mock(side_effect=_user_may_send_3pid_invite)

self.hs.get_spam_checker()._user_may_send_3pid_invite_callbacks.append(allow_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.
expected_call_args = (
(
self.user_id,
{"medium": "email", "address": email_to_invite},
self.room_id,
),
)

self.assertEquals(allow_mock.call_args, expected_call_args, allow_mock.call_args)

# Check that the call to send the invite was made.
self.assertEquals(make_invite_mock.call_count, 1)

# Now change the return value of the callback to deny any invite and test that
# we can't send the invite.
return_value = False
babolivier marked this conversation as resolved.
Show resolved Hide resolved
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.
self.assertEquals(make_invite_mock.call_count, 1)

def _mock_make_and_store_3pid_invite(self) -> Mock:
"""Mocks RoomMemberHandler._make_and_store_3pid_invite with a function that just
returns the integer 0.

Returns:
The Mock object _make_and_store_3pid_invite was replaced with.
"""
async def _make_and_store_3pid_invite(
requester: Requester,
id_server: str,
medium: str,
address: str,
room_id: str,
user: UserID,
txn_id: Optional[str],
id_access_token: Optional[str] = None,
) -> int:
return 0

mock = Mock(side_effect=_make_and_store_3pid_invite)

self.hs.get_room_member_handler()._make_and_store_3pid_invite = mock
babolivier marked this conversation as resolved.
Show resolved Hide resolved

return mock

def _mock_lookup_3pid(self) -> Mock:
"""Mocks IdentityHandler.lookup_3pid with a function that just returns None (ie
no binding for the 3PID.

Returns:
The Mock object lookup_3pid was replaced with.
"""
async def _lookup_3pid(
id_server: str,
medium: str,
address: str,
id_access_token: Optional[str] = None,
) -> Optional[str]:
return None

mock = Mock(side_effect=_lookup_3pid)

self.hs.get_identity_handler().lookup_3pid = mock

return mock