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

Correctly ratelimit invites when creating a room #9968

Merged
merged 11 commits into from
May 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/9968.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix a bug introduced in v1.27.0 preventing users and appservices exempt from ratelimiting from creating rooms with many invitees.
22 changes: 17 additions & 5 deletions synapse/api/ratelimiting.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ async def can_do_action(
rate_hz: Optional[float] = None,
burst_count: Optional[int] = None,
update: bool = True,
n_actions: int = 1,
_time_now_s: Optional[int] = None,
) -> Tuple[bool, float]:
"""Can the entity (e.g. user or IP address) perform the action?
Expand All @@ -76,6 +77,9 @@ async def can_do_action(
burst_count: How many actions that can be performed before being limited.
Overrides the value set during instantiation if set.
update: Whether to count this check as performing the action
n_actions: The number of times the user wants to do this action. If the user
cannot do all of the actions, the user's action count is not incremented
at all.
_time_now_s: The current time. Optional, defaults to the current time according
to self.clock. Only used by tests.

Expand Down Expand Up @@ -124,17 +128,20 @@ async def can_do_action(
time_delta = time_now_s - time_start
performed_count = action_count - time_delta * rate_hz
if performed_count < 0:
# Allow, reset back to count 1
allowed = True
performed_count = 0
time_start = time_now_s
action_count = 1.0
elif performed_count > burst_count - 1.0:

# This check would be easier read as performed_count + n_actions > burst_count,
# but performed_count might be a very precise float (with lots of numbers
# following the point) in which case Python might round it up when adding it to
# n_actions. Writing it this way ensures it doesn't happen.
if performed_count > burst_count - n_actions:
# Deny, we have exceeded our burst count
allowed = False
else:
# We haven't reached our limit yet
allowed = True
action_count += 1.0
action_count = performed_count + n_actions

if update:
self.actions[key] = (action_count, time_start, rate_hz)
Expand Down Expand Up @@ -182,6 +189,7 @@ async def ratelimit(
rate_hz: Optional[float] = None,
burst_count: Optional[int] = None,
update: bool = True,
n_actions: int = 1,
_time_now_s: Optional[int] = None,
):
"""Checks if an action can be performed. If not, raises a LimitExceededError
Expand All @@ -201,6 +209,9 @@ async def ratelimit(
burst_count: How many actions that can be performed before being limited.
Overrides the value set during instantiation if set.
update: Whether to count this check as performing the action
n_actions: The number of times the user wants to do this action. If the user
cannot do all of the actions, the user's action count is not incremented
at all.
_time_now_s: The current time. Optional, defaults to the current time according
to self.clock. Only used by tests.

Expand All @@ -216,6 +227,7 @@ async def ratelimit(
rate_hz=rate_hz,
burst_count=burst_count,
update=update,
n_actions=n_actions,
_time_now_s=time_now_s,
)

Expand Down
27 changes: 20 additions & 7 deletions synapse/handlers/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,14 @@
RoomCreationPreset,
RoomEncryptionAlgorithms,
)
from synapse.api.errors import AuthError, Codes, NotFoundError, StoreError, SynapseError
from synapse.api.errors import (
AuthError,
Codes,
LimitExceededError,
NotFoundError,
StoreError,
SynapseError,
)
from synapse.api.filtering import Filter
from synapse.api.room_versions import KNOWN_ROOM_VERSIONS, RoomVersion
from synapse.events import EventBase
Expand Down Expand Up @@ -126,10 +133,6 @@ def __init__(self, hs: "HomeServer"):

self.third_party_event_rules = hs.get_third_party_event_rules()

self._invite_burst_count = (
hs.config.ratelimiting.rc_invites_per_room.burst_count
)

async def upgrade_room(
self, requester: Requester, old_room_id: str, new_version: RoomVersion
) -> str:
Expand Down Expand Up @@ -676,8 +679,18 @@ async def create_room(
invite_3pid_list = []
invite_list = []

if len(invite_list) + len(invite_3pid_list) > self._invite_burst_count:
raise SynapseError(400, "Cannot invite so many users at once")
if invite_list or invite_3pid_list:
try:
# If there are invites in the request, see if the ratelimiting settings
# allow that number of invites to be sent from the current user.
await self.room_member_handler.ratelimit_multiple_invites(
requester,
room_id=None,
n_invites=len(invite_list) + len(invite_3pid_list),
update=False,
)
except LimitExceededError:
raise SynapseError(400, "Cannot invite so many users at once")

await self.event_creation_handler.assert_accepted_privacy_policy(requester)

Expand Down
25 changes: 25 additions & 0 deletions synapse/handlers/room_member.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,31 @@ async def _user_left_room(self, target: UserID, room_id: str) -> None:
async def forget(self, user: UserID, room_id: str) -> None:
raise NotImplementedError()

async def ratelimit_multiple_invites(
self,
requester: Optional[Requester],
room_id: Optional[str],
n_invites: int,
update: bool = True,
):
"""Ratelimit more than one invite sent by the given requester in the given room.

Args:
requester: The requester sending the invites.
room_id: The room the invites are being sent in.
n_invites: The amount of invites to ratelimit for.
update: Whether to update the ratelimiter's cache.

Raises:
LimitExceededError: The requester can't send that many invites in the room.
"""
await self._invites_per_room_limiter.ratelimit(
requester,
room_id,
update=update,
n_actions=n_invites,
)

async def ratelimit_invite(
self,
requester: Optional[Requester],
Expand Down
57 changes: 57 additions & 0 deletions tests/api/test_ratelimiting.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,60 @@ def test_db_user_override(self):
# Shouldn't raise
for _ in range(20):
self.get_success_or_raise(limiter.ratelimit(requester, _time_now_s=0))

def test_multiple_actions(self):
limiter = Ratelimiter(
store=self.hs.get_datastore(), clock=None, rate_hz=0.1, burst_count=3
)
# Test that 4 actions aren't allowed with a maximum burst of 3.
allowed, time_allowed = self.get_success_or_raise(
limiter.can_do_action(None, key="test_id", n_actions=4, _time_now_s=0)
)
self.assertFalse(allowed)

# Test that 3 actions are allowed with a maximum burst of 3.
allowed, time_allowed = self.get_success_or_raise(
limiter.can_do_action(None, key="test_id", n_actions=3, _time_now_s=0)
)
self.assertTrue(allowed)
self.assertEquals(10.0, time_allowed)

# Test that, after doing these 3 actions, we can't do any more action without
# waiting.
allowed, time_allowed = self.get_success_or_raise(
limiter.can_do_action(None, key="test_id", n_actions=1, _time_now_s=0)
)
self.assertFalse(allowed)
self.assertEquals(10.0, time_allowed)

# Test that after waiting we can do only 1 action.
allowed, time_allowed = self.get_success_or_raise(
limiter.can_do_action(
None,
key="test_id",
update=False,
n_actions=1,
_time_now_s=10,
)
)
self.assertTrue(allowed)
# The time allowed is the current time because we could still repeat the action
# once.
self.assertEquals(10.0, time_allowed)

allowed, time_allowed = self.get_success_or_raise(
limiter.can_do_action(None, key="test_id", n_actions=2, _time_now_s=10)
)
self.assertFalse(allowed)
# The time allowed doesn't change despite allowed being False because, while we
# don't allow 2 actions, we could still do 1.
self.assertEquals(10.0, time_allowed)

# Test that after waiting a bit more we can do 2 actions.
allowed, time_allowed = self.get_success_or_raise(
limiter.can_do_action(None, key="test_id", n_actions=2, _time_now_s=20)
)
self.assertTrue(allowed)
# The time allowed is the current time because we could still repeat the action
# once.
self.assertEquals(20.0, time_allowed)
37 changes: 37 additions & 0 deletions tests/rest/client/v1/test_rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,43 @@ def test_post_room_invitees_invalid_mxid(self):
)
self.assertEquals(400, channel.code)

@unittest.override_config({"rc_invites": {"per_room": {"burst_count": 3}}})
def test_post_room_invitees_ratelimit(self):
"""Test that invites sent when creating a room are ratelimited by a RateLimiter,
which ratelimits them correctly, including by not limiting when the requester is
exempt from ratelimiting.
"""

# Build the request's content. We use local MXIDs because invites over federation
# are more difficult to mock.
content = json.dumps(
{
"invite": [
"@alice1:red",
"@alice2:red",
"@alice3:red",
"@alice4:red",
]
}
).encode("utf8")

# Test that the invites are correctly ratelimited.
channel = self.make_request("POST", "/createRoom", content)
self.assertEqual(400, channel.code)
self.assertEqual(
"Cannot invite so many users at once",
channel.json_body["error"],
)

# Add the current user to the ratelimit overrides, allowing them no ratelimiting.
self.get_success(
self.hs.get_datastore().set_ratelimit_for_user(self.user_id, 0, 0)
)

# Test that the invites aren't ratelimited anymore.
channel = self.make_request("POST", "/createRoom", content)
self.assertEqual(200, channel.code)


class RoomTopicTestCase(RoomBase):
""" Tests /rooms/$room_id/topic REST events. """
Expand Down