From 9b2bc75ed49ea52008dc787a09337ca4a843d8e7 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 4 Jun 2025 13:09:11 +0100 Subject: Add ratelimit callbacks to module API to allow dynamic ratelimiting (#18458) --- changelog.d/18458.feature | 1 + docs/SUMMARY.md | 3 +- docs/modules/ratelimit_callbacks.md | 33 ++++++++++++ synapse/api/ratelimiting.py | 24 ++++++++- synapse/handlers/room_member.py | 3 ++ synapse/module_api/__init__.py | 19 ++++++- synapse/module_api/callbacks/__init__.py | 4 ++ .../module_api/callbacks/ratelimit_callbacks.py | 62 ++++++++++++++++++++++ synapse/storage/databases/main/room.py | 2 +- tests/api/test_ratelimiting.py | 50 +++++++++++++++++ 10 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 changelog.d/18458.feature create mode 100644 docs/modules/ratelimit_callbacks.md create mode 100644 synapse/module_api/callbacks/ratelimit_callbacks.py diff --git a/changelog.d/18458.feature b/changelog.d/18458.feature new file mode 100644 index 0000000000..92f11a8a2c --- /dev/null +++ b/changelog.d/18458.feature @@ -0,0 +1 @@ +Add a new module API callback that allows overriding of per user ratelimits. \ No newline at end of file diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index abb1d5603c..40d70a4485 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -49,7 +49,8 @@ - [Background update controller callbacks](modules/background_update_controller_callbacks.md) - [Account data callbacks](modules/account_data_callbacks.md) - [Add extra fields to client events unsigned section callbacks](modules/add_extra_fields_to_client_events_unsigned.md) - - [Media repository](modules/media_repository_callbacks.md) + - [Media repository callbacks](modules/media_repository_callbacks.md) + - [Ratelimit callbacks](modules/ratelimit_callbacks.md) - [Porting a legacy module to the new interface](modules/porting_legacy_module.md) - [Workers](workers.md) - [Using `synctl` with Workers](synctl_workers.md) diff --git a/docs/modules/ratelimit_callbacks.md b/docs/modules/ratelimit_callbacks.md new file mode 100644 index 0000000000..bf923c045e --- /dev/null +++ b/docs/modules/ratelimit_callbacks.md @@ -0,0 +1,33 @@ +# Ratelimit callbacks + +Ratelimit callbacks allow module developers to override ratelimit settings dynamically whilst +Synapse is running. Ratelimit callbacks can be registered using the module API's +`register_ratelimit_callbacks` method. + +The available ratelimit callbacks are: + +### `get_ratelimit_override_for_user` + +_First introduced in Synapse v1.132.0_ + +```python +async def get_ratelimit_override_for_user(user: str, limiter_name: str) -> Optional[RatelimitOverride] +``` + +Called when constructing a ratelimiter of a particular type for a user. The module can +return a `messages_per_second` and `burst_count` to be used, or `None` if +the default settings are adequate. The user is represented by their Matrix user ID +(e.g. `@alice:example.com`). The limiter name is usually taken from the `RatelimitSettings` key +value. + +The limiters that are currently supported are: + +- `rc_invites.per_room` +- `rc_invites.per_user` +- `rc_invites.per_issuer` + +If multiple modules implement this callback, they will be considered in order. If a +callback returns `None`, Synapse falls through to the next one. The value of the first +callback that does not return `None` will be used. If this happens, Synapse will not call +any of the subsequent implementations of this callback. If no module returns a non-`None` value +then the default settings will be used. diff --git a/synapse/api/ratelimiting.py b/synapse/api/ratelimiting.py index 8665b3b765..38e5bdaa75 100644 --- a/synapse/api/ratelimiting.py +++ b/synapse/api/ratelimiting.py @@ -20,7 +20,7 @@ # # -from typing import Dict, Hashable, Optional, Tuple +from typing import TYPE_CHECKING, Dict, Hashable, Optional, Tuple from synapse.api.errors import LimitExceededError from synapse.config.ratelimiting import RatelimitSettings @@ -28,6 +28,12 @@ from synapse.storage.databases.main import DataStore from synapse.types import Requester from synapse.util import Clock +if TYPE_CHECKING: + # To avoid circular imports: + from synapse.module_api.callbacks.ratelimit_callbacks import ( + RatelimitModuleApiCallbacks, + ) + class Ratelimiter: """ @@ -72,12 +78,14 @@ class Ratelimiter: store: DataStore, clock: Clock, cfg: RatelimitSettings, + ratelimit_callbacks: Optional["RatelimitModuleApiCallbacks"] = None, ): self.clock = clock self.rate_hz = cfg.per_second self.burst_count = cfg.burst_count self.store = store self._limiter_name = cfg.key + self._ratelimit_callbacks = ratelimit_callbacks # A dictionary representing the token buckets tracked by this rate # limiter. Each entry maps a key of arbitrary type to a tuple representing: @@ -165,6 +173,20 @@ class Ratelimiter: if override and not override.messages_per_second: return True, -1.0 + if requester and self._ratelimit_callbacks: + # Check if the user has a custom rate limit for this specific limiter + # as returned by the module API. + module_override = ( + await self._ratelimit_callbacks.get_ratelimit_override_for_user( + requester.user.to_string(), + self._limiter_name, + ) + ) + + if module_override: + rate_hz = module_override.messages_per_second + burst_count = module_override.burst_count + # Override default values if set time_now_s = _time_now_s if _time_now_s is not None else self.clock.time() rate_hz = rate_hz if rate_hz is not None else self.rate_hz diff --git a/synapse/handlers/room_member.py b/synapse/handlers/room_member.py index 70cbbc352b..b0e750c9c7 100644 --- a/synapse/handlers/room_member.py +++ b/synapse/handlers/room_member.py @@ -158,6 +158,7 @@ class RoomMemberHandler(metaclass=abc.ABCMeta): store=self.store, clock=self.clock, cfg=hs.config.ratelimiting.rc_invites_per_room, + ratelimit_callbacks=hs.get_module_api_callbacks().ratelimit, ) # Ratelimiter for invites, keyed by recipient (across all rooms, all @@ -166,6 +167,7 @@ class RoomMemberHandler(metaclass=abc.ABCMeta): store=self.store, clock=self.clock, cfg=hs.config.ratelimiting.rc_invites_per_user, + ratelimit_callbacks=hs.get_module_api_callbacks().ratelimit, ) # Ratelimiter for invites, keyed by issuer (across all rooms, all @@ -174,6 +176,7 @@ class RoomMemberHandler(metaclass=abc.ABCMeta): store=self.store, clock=self.clock, cfg=hs.config.ratelimiting.rc_invites_per_issuer, + ratelimit_callbacks=hs.get_module_api_callbacks().ratelimit, ) self._third_party_invite_limiter = Ratelimiter( diff --git a/synapse/module_api/__init__.py b/synapse/module_api/__init__.py index 3148957229..e36503a526 100644 --- a/synapse/module_api/__init__.py +++ b/synapse/module_api/__init__.py @@ -94,6 +94,9 @@ from synapse.module_api.callbacks.media_repository_callbacks import ( GET_MEDIA_CONFIG_FOR_USER_CALLBACK, IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK, ) +from synapse.module_api.callbacks.ratelimit_callbacks import ( + GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK, +) from synapse.module_api.callbacks.spamchecker_callbacks import ( CHECK_EVENT_FOR_SPAM_CALLBACK, CHECK_LOGIN_FOR_SPAM_CALLBACK, @@ -367,6 +370,20 @@ class ModuleApi: on_legacy_admin_request=on_legacy_admin_request, ) + def register_ratelimit_callbacks( + self, + *, + get_ratelimit_override_for_user: Optional[ + GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK + ] = None, + ) -> None: + """Registers callbacks for ratelimit capabilities. + Added in Synapse v1.132.0. + """ + return self._callbacks.ratelimit.register_callbacks( + get_ratelimit_override_for_user=get_ratelimit_override_for_user, + ) + def register_media_repository_callbacks( self, *, @@ -376,7 +393,7 @@ class ModuleApi: ] = None, ) -> None: """Registers callbacks for media repository capabilities. - Added in Synapse v1.x.x. + Added in Synapse v1.132.0. """ return self._callbacks.media_repository.register_callbacks( get_media_config_for_user=get_media_config_for_user, diff --git a/synapse/module_api/callbacks/__init__.py b/synapse/module_api/callbacks/__init__.py index a36c0fc7c6..16ef7a4b47 100644 --- a/synapse/module_api/callbacks/__init__.py +++ b/synapse/module_api/callbacks/__init__.py @@ -30,6 +30,9 @@ from synapse.module_api.callbacks.account_validity_callbacks import ( from synapse.module_api.callbacks.media_repository_callbacks import ( MediaRepositoryModuleApiCallbacks, ) +from synapse.module_api.callbacks.ratelimit_callbacks import ( + RatelimitModuleApiCallbacks, +) from synapse.module_api.callbacks.spamchecker_callbacks import ( SpamCheckerModuleApiCallbacks, ) @@ -42,5 +45,6 @@ class ModuleApiCallbacks: def __init__(self, hs: "HomeServer") -> None: self.account_validity = AccountValidityModuleApiCallbacks() self.media_repository = MediaRepositoryModuleApiCallbacks(hs) + self.ratelimit = RatelimitModuleApiCallbacks(hs) self.spam_checker = SpamCheckerModuleApiCallbacks(hs) self.third_party_event_rules = ThirdPartyEventRulesModuleApiCallbacks(hs) diff --git a/synapse/module_api/callbacks/ratelimit_callbacks.py b/synapse/module_api/callbacks/ratelimit_callbacks.py new file mode 100644 index 0000000000..ced721b407 --- /dev/null +++ b/synapse/module_api/callbacks/ratelimit_callbacks.py @@ -0,0 +1,62 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2025 New Vector, Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . +# + +import logging +from typing import TYPE_CHECKING, Awaitable, Callable, List, Optional + +from synapse.storage.databases.main.room import RatelimitOverride +from synapse.util.async_helpers import delay_cancellation +from synapse.util.metrics import Measure + +if TYPE_CHECKING: + from synapse.server import HomeServer + +logger = logging.getLogger(__name__) + +GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK = Callable[ + [str, str], Awaitable[Optional[RatelimitOverride]] +] + + +class RatelimitModuleApiCallbacks: + def __init__(self, hs: "HomeServer") -> None: + self.clock = hs.get_clock() + self._get_ratelimit_override_for_user_callbacks: List[ + GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK + ] = [] + + def register_callbacks( + self, + get_ratelimit_override_for_user: Optional[ + GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK + ] = None, + ) -> None: + """Register callbacks from module for each hook.""" + if get_ratelimit_override_for_user is not None: + self._get_ratelimit_override_for_user_callbacks.append( + get_ratelimit_override_for_user + ) + + async def get_ratelimit_override_for_user( + self, user_id: str, limiter_name: str + ) -> Optional[RatelimitOverride]: + for callback in self._get_ratelimit_override_for_user_callbacks: + with Measure(self.clock, f"{callback.__module__}.{callback.__qualname__}"): + res: Optional[RatelimitOverride] = await delay_cancellation( + callback(user_id, limiter_name) + ) + if res: + return res + + return None diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index 56217fccdf..46491cc0fe 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -77,7 +77,7 @@ logger = logging.getLogger(__name__) @attr.s(slots=True, frozen=True, auto_attribs=True) class RatelimitOverride: - messages_per_second: int + messages_per_second: float burst_count: int diff --git a/tests/api/test_ratelimiting.py b/tests/api/test_ratelimiting.py index 1a1cbde74e..5e73f5d5ec 100644 --- a/tests/api/test_ratelimiting.py +++ b/tests/api/test_ratelimiting.py @@ -1,6 +1,10 @@ +from typing import Optional + from synapse.api.ratelimiting import LimitExceededError, Ratelimiter from synapse.appservice import ApplicationService from synapse.config.ratelimiting import RatelimitSettings +from synapse.module_api.callbacks.ratelimit_callbacks import RatelimitModuleApiCallbacks +from synapse.storage.databases.main.room import RatelimitOverride from synapse.types import create_requester from tests import unittest @@ -440,3 +444,49 @@ class TestRatelimiter(unittest.HomeserverTestCase): limiter.can_do_action(requester=None, key="a", _time_now_s=20.0) ) self.assertTrue(success) + + def test_get_ratelimit_override_for_user_callback(self) -> None: + test_user_id = "@user:test" + test_limiter_name = "name" + callbacks = RatelimitModuleApiCallbacks(self.hs) + requester = create_requester(test_user_id) + limiter = Ratelimiter( + store=self.hs.get_datastores().main, + clock=self.clock, + cfg=RatelimitSettings( + test_limiter_name, + per_second=0.1, + burst_count=3, + ), + ratelimit_callbacks=callbacks, + ) + + # Observe four actions, exceeding the burst_count. + limiter.record_action(requester=requester, n_actions=4, _time_now_s=0.0) + + # We should be prevented from taking a new action now. + success, _ = self.get_success_or_raise( + limiter.can_do_action(requester=requester, _time_now_s=0.0) + ) + self.assertFalse(success) + + # Now register a callback that overrides the ratelimit for this user + # and limiter name. + async def get_ratelimit_override_for_user( + user_id: str, limiter_name: str + ) -> Optional[RatelimitOverride]: + if user_id == test_user_id: + return RatelimitOverride( + messages_per_second=0.1, + burst_count=10, + ) + return None + + callbacks.register_callbacks( + get_ratelimit_override_for_user=get_ratelimit_override_for_user + ) + + success, _ = self.get_success_or_raise( + limiter.can_do_action(requester=requester, _time_now_s=0.0) + ) + self.assertTrue(success) -- cgit 1.5.1