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

Commit 6bcb28b

Browse files
Add a spamchecker callback to allow or deny room joins (#10910)
Co-authored-by: Erik Johnston <erik@matrix.org>
1 parent 262163c commit 6bcb28b

File tree

5 files changed

+156
-6
lines changed

5 files changed

+156
-6
lines changed

changelog.d/10910.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a spam checker callback to allow or deny room joins.

docs/modules/spam_checker_callbacks.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,21 @@ either a `bool` to indicate whether the event must be rejected because of spam,
1919
to indicate the event must be rejected because of spam and to give a rejection reason to
2020
forward to clients.
2121

22+
### `user_may_join_room`
23+
24+
```python
25+
async def user_may_join_room(user: str, room: str, is_invited: bool) -> bool
26+
```
27+
28+
Called when a user is trying to join a room. The module must return a `bool` to indicate
29+
whether the user can join the room. The user is represented by their Matrix user ID (e.g.
30+
`@alice:example.com`) and the room is represented by its Matrix ID (e.g.
31+
`!room:example.com`). The module is also given a boolean to indicate whether the user
32+
currently has a pending invite in the room.
33+
34+
This callback isn't called if the join is performed by a server administrator, or in the
35+
context of a room creation.
36+
2237
### `user_may_invite`
2338

2439
```python

synapse/events/spamcheck.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
["synapse.events.EventBase"],
4545
Awaitable[Union[bool, str]],
4646
]
47+
USER_MAY_JOIN_ROOM_CALLBACK = Callable[[str, str, bool], Awaitable[bool]]
4748
USER_MAY_INVITE_CALLBACK = Callable[[str, str, str], Awaitable[bool]]
4849
USER_MAY_CREATE_ROOM_CALLBACK = Callable[[str], Awaitable[bool]]
4950
USER_MAY_CREATE_ROOM_WITH_INVITES_CALLBACK = Callable[
@@ -168,6 +169,7 @@ def run(*args, **kwargs):
168169
class SpamChecker:
169170
def __init__(self):
170171
self._check_event_for_spam_callbacks: List[CHECK_EVENT_FOR_SPAM_CALLBACK] = []
172+
self._user_may_join_room_callbacks: List[USER_MAY_JOIN_ROOM_CALLBACK] = []
171173
self._user_may_invite_callbacks: List[USER_MAY_INVITE_CALLBACK] = []
172174
self._user_may_create_room_callbacks: List[USER_MAY_CREATE_ROOM_CALLBACK] = []
173175
self._user_may_create_room_with_invites_callbacks: List[
@@ -191,6 +193,7 @@ def __init__(self):
191193
def register_callbacks(
192194
self,
193195
check_event_for_spam: Optional[CHECK_EVENT_FOR_SPAM_CALLBACK] = None,
196+
user_may_join_room: Optional[USER_MAY_JOIN_ROOM_CALLBACK] = None,
194197
user_may_invite: Optional[USER_MAY_INVITE_CALLBACK] = None,
195198
user_may_create_room: Optional[USER_MAY_CREATE_ROOM_CALLBACK] = None,
196199
user_may_create_room_with_invites: Optional[
@@ -211,6 +214,9 @@ def register_callbacks(
211214
if check_event_for_spam is not None:
212215
self._check_event_for_spam_callbacks.append(check_event_for_spam)
213216

217+
if user_may_join_room is not None:
218+
self._user_may_join_room_callbacks.append(user_may_join_room)
219+
214220
if user_may_invite is not None:
215221
self._user_may_invite_callbacks.append(user_may_invite)
216222

@@ -267,6 +273,24 @@ async def check_event_for_spam(
267273

268274
return False
269275

276+
async def user_may_join_room(self, user_id: str, room_id: str, is_invited: bool):
277+
"""Checks if a given users is allowed to join a room.
278+
Not called when a user creates a room.
279+
280+
Args:
281+
userid: The ID of the user wanting to join the room
282+
room_id: The ID of the room the user wants to join
283+
is_invited: Whether the user is invited into the room
284+
285+
Returns:
286+
bool: Whether the user may join the room
287+
"""
288+
for callback in self._user_may_join_room_callbacks:
289+
if await callback(user_id, room_id, is_invited) is False:
290+
return False
291+
292+
return True
293+
270294
async def user_may_invite(
271295
self,
272296
inviter_userid: str,

synapse/handlers/room_member.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,8 @@ async def update_membership(
449449
third_party_signed: Information from a 3PID invite.
450450
ratelimit: Whether to rate limit the request.
451451
content: The content of the created event.
452+
new_room: Whether the membership update is happening in the context of a room
453+
creation.
452454
require_consent: Whether consent is required.
453455
outlier: Indicates whether the event is an `outlier`, i.e. if
454456
it's from an arbitrary point and floating in the DAG as
@@ -523,6 +525,8 @@ async def update_membership_locked(
523525
third_party_signed:
524526
ratelimit:
525527
content:
528+
new_room: Whether the membership update is happening in the context of a room
529+
creation.
526530
require_consent:
527531
outlier: Indicates whether the event is an `outlier`, i.e. if
528532
it's from an arbitrary point and floating in the DAG as
@@ -711,24 +715,29 @@ async def update_membership_locked(
711715
# so don't really fit into the general auth process.
712716
raise AuthError(403, "Guest access not allowed")
713717

718+
# Figure out whether the user is a server admin to determine whether they
719+
# should be able to bypass the spam checker.
714720
if (
715721
self._server_notices_mxid is not None
716722
and requester.user.to_string() == self._server_notices_mxid
717723
):
718724
# allow the server notices mxid to join rooms
719-
is_requester_admin = True
725+
bypass_spam_checker = True
720726

721727
else:
722-
is_requester_admin = await self.auth.is_server_admin(requester.user)
728+
bypass_spam_checker = await self.auth.is_server_admin(requester.user)
723729

724730
inviter = await self._get_inviter(target.to_string(), room_id)
725-
if not is_requester_admin:
731+
if (
732+
not bypass_spam_checker
726733
# We assume that if the spam checker allowed the user to create
727734
# a room then they're allowed to join it.
728-
if not new_room and not await self.spam_checker.user_may_join_room(
735+
and not new_room
736+
and not await self.spam_checker.user_may_join_room(
729737
target.to_string(), room_id, is_invited=inviter is not None
730-
):
731-
raise SynapseError(403, "Not allowed to join this room")
738+
)
739+
):
740+
raise SynapseError(403, "Not allowed to join this room")
732741

733742
# Check if a remote join should be performed.
734743
remote_join, remote_room_hosts = await self._should_perform_remote_join(

tests/rest/client/v1/test_rooms.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,30 @@ async def user_may_create_room_with_invites(
699699
# Check that do_3pid_invite wasn't called this time.
700700
self.assertEquals(do_3pid_invite_mock.call_count, len(invited_3pids))
701701

702+
def test_spam_checker_may_join_room(self):
703+
"""Tests that the user_may_join_room spam checker callback is correctly bypassed
704+
when creating a new room.
705+
"""
706+
707+
async def user_may_join_room(
708+
mxid: str,
709+
room_id: str,
710+
is_invite: bool,
711+
) -> bool:
712+
return False
713+
714+
join_mock = Mock(side_effect=user_may_join_room)
715+
self.hs.get_spam_checker()._user_may_join_room_callbacks.append(join_mock)
716+
717+
channel = self.make_request(
718+
"POST",
719+
"/createRoom",
720+
{},
721+
)
722+
self.assertEquals(channel.code, 200, channel.json_body)
723+
724+
self.assertEquals(join_mock.call_count, 0)
725+
702726

703727
class RoomTopicTestCase(RoomBase):
704728
"""Tests /rooms/$room_id/topic REST events."""
@@ -890,6 +914,83 @@ def test_invites_by_users_ratelimit(self):
890914
self.helper.invite(room_id, self.user_id, "@other-users:red", expect_code=429)
891915

892916

917+
class RoomJoinTestCase(RoomBase):
918+
919+
servlets = [
920+
admin.register_servlets,
921+
login.register_servlets,
922+
room.register_servlets,
923+
]
924+
925+
def prepare(self, reactor, clock, homeserver):
926+
self.user1 = self.register_user("thomas", "hackme")
927+
self.tok1 = self.login("thomas", "hackme")
928+
929+
self.user2 = self.register_user("teresa", "hackme")
930+
self.tok2 = self.login("teresa", "hackme")
931+
932+
self.room1 = self.helper.create_room_as(room_creator=self.user1, tok=self.tok1)
933+
self.room2 = self.helper.create_room_as(room_creator=self.user1, tok=self.tok1)
934+
self.room3 = self.helper.create_room_as(room_creator=self.user1, tok=self.tok1)
935+
936+
def test_spam_checker_may_join_room(self):
937+
"""Tests that the user_may_join_room spam checker callback is correctly called
938+
and blocks room joins when needed.
939+
"""
940+
941+
# Register a dummy callback. Make it allow all room joins for now.
942+
return_value = True
943+
944+
async def user_may_join_room(
945+
userid: str,
946+
room_id: str,
947+
is_invited: bool,
948+
) -> bool:
949+
return return_value
950+
951+
callback_mock = Mock(side_effect=user_may_join_room)
952+
self.hs.get_spam_checker()._user_may_join_room_callbacks.append(callback_mock)
953+
954+
# Join a first room, without being invited to it.
955+
self.helper.join(self.room1, self.user2, tok=self.tok2)
956+
957+
# Check that the callback was called with the right arguments.
958+
expected_call_args = (
959+
(
960+
self.user2,
961+
self.room1,
962+
False,
963+
),
964+
)
965+
self.assertEquals(
966+
callback_mock.call_args,
967+
expected_call_args,
968+
callback_mock.call_args,
969+
)
970+
971+
# Join a second room, this time with an invite for it.
972+
self.helper.invite(self.room2, self.user1, self.user2, tok=self.tok1)
973+
self.helper.join(self.room2, self.user2, tok=self.tok2)
974+
975+
# Check that the callback was called with the right arguments.
976+
expected_call_args = (
977+
(
978+
self.user2,
979+
self.room2,
980+
True,
981+
),
982+
)
983+
self.assertEquals(
984+
callback_mock.call_args,
985+
expected_call_args,
986+
callback_mock.call_args,
987+
)
988+
989+
# Now make the callback deny all room joins, and check that a join actually fails.
990+
return_value = False
991+
self.helper.join(self.room3, self.user2, expect_code=403, tok=self.tok2)
992+
993+
893994
class RoomJoinRatelimitTestCase(RoomBase):
894995
user_id = "@sid1:red"
895996

0 commit comments

Comments
 (0)