From a016de0efc8d9da5dc1dce617b8bdbad11c9b4dc Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 21 May 2026 11:28:20 -0400 Subject: [PATCH 01/14] MSC4140: allow auth on management endpoints This is to allow authed requests to have their own ratelimit quotas. --- synapse/handlers/delayed_events.py | 34 ++++++------- tests/rest/client/test_delayed_events.py | 62 +++++++++++++++++++----- 2 files changed, 66 insertions(+), 30 deletions(-) diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py index 4a9f646d4db..4ca925bf932 100644 --- a/synapse/handlers/delayed_events.py +++ b/synapse/handlers/delayed_events.py @@ -61,13 +61,13 @@ def __init__(self, hs: "HomeServer"): self._storage_controllers = hs.get_storage_controllers() self._config = hs.config self._clock = hs.get_clock() + self._auth = hs.get_auth() self._event_creation_handler = hs.get_event_creation_handler() self._room_member_handler = hs.get_room_member_handler() self._request_ratelimiter = hs.get_request_ratelimiter() - # Ratelimiter for management of existing delayed events, - # keyed by the sending user ID & device ID. + # Ratelimiters for management of existing delayed events self._delayed_event_mgmt_ratelimiter = Ratelimiter( store=self._store, clock=self._clock, @@ -413,9 +413,7 @@ async def cancel(self, request: SynapseRequest, delay_id: str) -> None: NotFoundError: if no matching delayed event could be found. """ assert self._is_master - await self._delayed_event_mgmt_ratelimiter.ratelimit( - None, request.getClientAddress().host - ) + await self._mgmt_ratelimit(request) await make_deferred_yieldable(self._initialized_from_db) next_send_ts = await self._store.cancel_delayed_event(delay_id) @@ -430,9 +428,7 @@ async def restart(self, request: SynapseRequest, delay_id: str) -> None: Raises: NotFoundError: if no matching delayed event could be found. """ - await self._delayed_event_mgmt_ratelimiter.ratelimit( - None, request.getClientAddress().host - ) + await self._mgmt_ratelimit(request) # Note: We don't need to wait on `self._initialized_from_db` here as the # events that deals with are already marked as processed. @@ -456,9 +452,7 @@ async def send(self, request: SynapseRequest, delay_id: str) -> None: NotFoundError: if no matching delayed event could be found. """ assert self._is_master - await self._delayed_event_mgmt_ratelimiter.ratelimit( - None, request.getClientAddress().host - ) + await self._mgmt_ratelimit(request) await make_deferred_yieldable(self._initialized_from_db) event, next_send_ts = await self._store.process_target_delayed_event(delay_id) @@ -468,6 +462,15 @@ async def send(self, request: SynapseRequest, delay_id: str) -> None: await self._send_event(event) + async def _mgmt_ratelimit(self, request: SynapseRequest) -> None: + if self._auth.has_access_token(request): + requester = await self._auth.get_user_by_req(request) + key = None + else: + requester = None + key = request.getClientAddress().host + await self._delayed_event_mgmt_ratelimiter.ratelimit(requester, key) + async def _send_on_timeout(self) -> None: self._next_delayed_event_call = None @@ -527,13 +530,8 @@ def _schedule_next_at(self, next_send_ts: Timestamp) -> None: async def get_all_for_user(self, requester: Requester) -> list[JsonDict]: """Return all pending delayed events requested by the given user.""" - await self._delayed_event_mgmt_ratelimiter.ratelimit( - requester, - (requester.user.to_string(), requester.device_id), - ) - return await self._store.get_all_delayed_events_for_user( - requester.user.localpart - ) + await self._delayed_event_mgmt_ratelimiter.ratelimit(requester) + return await self._store.get_all_delayed_events_for_user(requester.user.localpart) async def _send_event( self, diff --git a/tests/rest/client/test_delayed_events.py b/tests/rest/client/test_delayed_events.py index da904ce1f51..caf453f17b5 100644 --- a/tests/rest/client/test_delayed_events.py +++ b/tests/rest/client/test_delayed_events.py @@ -317,7 +317,7 @@ def test_cancel_delayed_state_event(self, action_in_path: bool) -> None: ) def test_cancel_delayed_event_ratelimit(self, action_in_path: bool) -> None: delay_ids = [] - for _ in range(2): + for _ in range(3): channel = self.make_request( "POST", _get_path_for_delayed_send(self.room_id, _EVENT_TYPE, 100000), @@ -329,12 +329,33 @@ def test_cancel_delayed_event_ratelimit(self, action_in_path: bool) -> None: assert delay_id is not None delay_ids.append(delay_id) - channel = self._update_delayed_event(delay_ids.pop(0), "cancel", action_in_path) + delay_id = delay_ids.pop(0) + channel = self._update_delayed_event(delay_id, "cancel", action_in_path) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) - channel = self._update_delayed_event(delay_ids.pop(0), "cancel", action_in_path) + delay_id = delay_ids.pop(0) + channel = self._update_delayed_event(delay_id, "cancel", action_in_path) self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) + # Using auth should bypass ratelimit applied against source IP + channel = self._update_delayed_event(delay_id, "cancel", action_in_path, self.user1_access_token) + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + + delay_id = delay_ids.pop(0) + channel = self._update_delayed_event(delay_id, "cancel", action_in_path, self.user1_access_token) + self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) + + # Add the current user to the ratelimit overrides, allowing them no ratelimiting. + self.get_success( + self.hs.get_datastores().main.set_ratelimit_for_user( + self.user1_user_id, 0, 0 + ) + ) + + # Test that the request isn't ratelimited anymore. + channel = self._update_delayed_event(delay_id, "cancel", action_in_path, self.user1_access_token) + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + @parameterized.expand( ( (content_property_value, action_in_path) @@ -475,7 +496,7 @@ def test_restart_delayed_state_event(self, action_in_path: bool) -> None: ) def test_restart_delayed_event_ratelimit(self, action_in_path: bool) -> None: delay_ids = [] - for _ in range(2): + for _ in range(3): channel = self.make_request( "POST", _get_path_for_delayed_send(self.room_id, _EVENT_TYPE, 100000), @@ -487,16 +508,33 @@ def test_restart_delayed_event_ratelimit(self, action_in_path: bool) -> None: assert delay_id is not None delay_ids.append(delay_id) - channel = self._update_delayed_event( - delay_ids.pop(0), "restart", action_in_path - ) + delay_id = delay_ids.pop(0) + channel = self._update_delayed_event(delay_id, "restart", action_in_path) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) - channel = self._update_delayed_event( - delay_ids.pop(0), "restart", action_in_path - ) + delay_id = delay_ids.pop(0) + channel = self._update_delayed_event(delay_id, "restart", action_in_path) self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) + # Using auth should bypass ratelimit applied against source IP + channel = self._update_delayed_event(delay_id, "restart", action_in_path, self.user1_access_token) + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + + delay_id = delay_ids.pop(0) + channel = self._update_delayed_event(delay_id, "restart", action_in_path, self.user1_access_token) + self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) + + # Add the current user to the ratelimit overrides, allowing them no ratelimiting. + self.get_success( + self.hs.get_datastores().main.set_ratelimit_for_user( + self.user1_user_id, 0, 0 + ) + ) + + # Test that the request isn't ratelimited anymore. + channel = self._update_delayed_event(delay_id, "restart", action_in_path, self.user1_access_token) + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + def test_delayed_state_is_not_cancelled_by_new_state_from_same_user( self, ) -> None: @@ -612,7 +650,7 @@ def _get_delayed_event_content(self, event: JsonDict) -> JsonDict: return content def _update_delayed_event( - self, delay_id: str, action: str, action_in_path: bool + self, delay_id: str, action: str, action_in_path: bool, access_token: str | None = None ) -> FakeChannel: path = f"{PATH_PREFIX}/{delay_id}" body = {} @@ -620,7 +658,7 @@ def _update_delayed_event( path += f"/{action}" else: body["action"] = action - return self.make_request("POST", path, body) + return self.make_request("POST", path, body, access_token) def _find_sent_delayed_event( self, access_token: str, delay_id: str, should_find: bool From 6526be925babf097e8f261cbbb32b44e719d26a0 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 21 May 2026 11:33:19 -0400 Subject: [PATCH 02/14] Add changelog --- changelog.d/19794.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/19794.feature diff --git a/changelog.d/19794.feature b/changelog.d/19794.feature new file mode 100644 index 00000000000..d6504d3882c --- /dev/null +++ b/changelog.d/19794.feature @@ -0,0 +1 @@ +[MSC4140: Cancellable delayed events](https://github.com/matrix-org/matrix-spec-proposals/pull/4140): Allow authentication on delayed event management endpoints (such as `/restart`) to bypass ratelimits based on the client IP address. From 33eca5e0e323d1e4e3b497c1015a029343aea338 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 21 May 2026 11:37:08 -0400 Subject: [PATCH 03/14] Lint --- synapse/handlers/delayed_events.py | 4 +++- tests/rest/client/test_delayed_events.py | 30 ++++++++++++++++++------ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py index 4ca925bf932..9880601c9ff 100644 --- a/synapse/handlers/delayed_events.py +++ b/synapse/handlers/delayed_events.py @@ -531,7 +531,9 @@ def _schedule_next_at(self, next_send_ts: Timestamp) -> None: async def get_all_for_user(self, requester: Requester) -> list[JsonDict]: """Return all pending delayed events requested by the given user.""" await self._delayed_event_mgmt_ratelimiter.ratelimit(requester) - return await self._store.get_all_delayed_events_for_user(requester.user.localpart) + return await self._store.get_all_delayed_events_for_user( + requester.user.localpart + ) async def _send_event( self, diff --git a/tests/rest/client/test_delayed_events.py b/tests/rest/client/test_delayed_events.py index caf453f17b5..c3bfdf7c8d4 100644 --- a/tests/rest/client/test_delayed_events.py +++ b/tests/rest/client/test_delayed_events.py @@ -338,11 +338,15 @@ def test_cancel_delayed_event_ratelimit(self, action_in_path: bool) -> None: self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) # Using auth should bypass ratelimit applied against source IP - channel = self._update_delayed_event(delay_id, "cancel", action_in_path, self.user1_access_token) + channel = self._update_delayed_event( + delay_id, "cancel", action_in_path, self.user1_access_token + ) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) delay_id = delay_ids.pop(0) - channel = self._update_delayed_event(delay_id, "cancel", action_in_path, self.user1_access_token) + channel = self._update_delayed_event( + delay_id, "cancel", action_in_path, self.user1_access_token + ) self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) # Add the current user to the ratelimit overrides, allowing them no ratelimiting. @@ -353,7 +357,9 @@ def test_cancel_delayed_event_ratelimit(self, action_in_path: bool) -> None: ) # Test that the request isn't ratelimited anymore. - channel = self._update_delayed_event(delay_id, "cancel", action_in_path, self.user1_access_token) + channel = self._update_delayed_event( + delay_id, "cancel", action_in_path, self.user1_access_token + ) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) @parameterized.expand( @@ -517,11 +523,15 @@ def test_restart_delayed_event_ratelimit(self, action_in_path: bool) -> None: self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) # Using auth should bypass ratelimit applied against source IP - channel = self._update_delayed_event(delay_id, "restart", action_in_path, self.user1_access_token) + channel = self._update_delayed_event( + delay_id, "restart", action_in_path, self.user1_access_token + ) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) delay_id = delay_ids.pop(0) - channel = self._update_delayed_event(delay_id, "restart", action_in_path, self.user1_access_token) + channel = self._update_delayed_event( + delay_id, "restart", action_in_path, self.user1_access_token + ) self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) # Add the current user to the ratelimit overrides, allowing them no ratelimiting. @@ -532,7 +542,9 @@ def test_restart_delayed_event_ratelimit(self, action_in_path: bool) -> None: ) # Test that the request isn't ratelimited anymore. - channel = self._update_delayed_event(delay_id, "restart", action_in_path, self.user1_access_token) + channel = self._update_delayed_event( + delay_id, "restart", action_in_path, self.user1_access_token + ) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) def test_delayed_state_is_not_cancelled_by_new_state_from_same_user( @@ -650,7 +662,11 @@ def _get_delayed_event_content(self, event: JsonDict) -> JsonDict: return content def _update_delayed_event( - self, delay_id: str, action: str, action_in_path: bool, access_token: str | None = None + self, + delay_id: str, + action: str, + action_in_path: bool, + access_token: str | None = None, ) -> FakeChannel: path = f"{PATH_PREFIX}/{delay_id}" body = {} From f2ba5b396dda5d27bda016fb045f46245e7bc95d Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 21 May 2026 11:39:13 -0400 Subject: [PATCH 04/14] Update ratelimit documentation Plus, since #19152, the delayed event management ratelimit hasn't considered the requesting device ID, so don't mention that anymore. --- docs/usage/configuration/config_documentation.md | 2 +- schema/synapse-config.schema.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/usage/configuration/config_documentation.md b/docs/usage/configuration/config_documentation.md index d028d65fe33..4338aa9d82f 100644 --- a/docs/usage/configuration/config_documentation.md +++ b/docs/usage/configuration/config_documentation.md @@ -1971,7 +1971,7 @@ rc_presence: *(object)* Ratelimiting settings for delayed event management. -This is a ratelimiting option that ratelimits attempts to restart, cancel, or view delayed events based on the sending client's account and device ID. +This is a ratelimiting option that ratelimits attempts to restart, cancel, or view delayed events based on the sending client's account, or its source IP when unauthenticated. Attempts to create or send delayed events are ratelimited not by this setting, but by `rc_message`. diff --git a/schema/synapse-config.schema.yaml b/schema/synapse-config.schema.yaml index 8b8d57b9bf1..9e88e449842 100644 --- a/schema/synapse-config.schema.yaml +++ b/schema/synapse-config.schema.yaml @@ -2244,8 +2244,8 @@ properties: This is a ratelimiting option that ratelimits attempts to restart, cancel, - or view delayed events based on the sending client's account and device - ID. + or view delayed events based on the sending client's account, + or its source IP when unauthenticated. Attempts to create or send delayed events are ratelimited not by this From 239c6f461e230b920fff67432e59cc2ebee44e4b Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 5 Jun 2026 12:00:10 -0400 Subject: [PATCH 05/14] MSC4140: rate limit management against delay_id Do this instead of rate limiting based on IP address, to avoid the possibility of multiple unauthenticated clients on a shared IP address getting put into the same rate-limit quota and getting unfairly blocked. This follows the suggestion made by the MSC: https://github.com/matrix-org/matrix-spec-proposals/blob/toger5/expiring-events-keep-alive/proposals/4140-delayed-events-futures.md#rate-limiting-for-heartbeats --- synapse/handlers/delayed_events.py | 32 ++++++++++--------- .../storage/databases/main/delayed_events.py | 17 ++++++++++ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py index 9880601c9ff..dca2881a6bb 100644 --- a/synapse/handlers/delayed_events.py +++ b/synapse/handlers/delayed_events.py @@ -413,8 +413,7 @@ async def cancel(self, request: SynapseRequest, delay_id: str) -> None: NotFoundError: if no matching delayed event could be found. """ assert self._is_master - await self._mgmt_ratelimit(request) - await make_deferred_yieldable(self._initialized_from_db) + await self._mgmt_ratelimit(request, delay_id) next_send_ts = await self._store.cancel_delayed_event(delay_id) @@ -428,13 +427,7 @@ async def restart(self, request: SynapseRequest, delay_id: str) -> None: Raises: NotFoundError: if no matching delayed event could be found. """ - await self._mgmt_ratelimit(request) - - # Note: We don't need to wait on `self._initialized_from_db` here as the - # events that deals with are already marked as processed. - # - # `restart_delayed_events` will skip over such events entirely. - + await self._mgmt_ratelimit(request, delay_id) next_send_ts = await self._store.restart_delayed_event( delay_id, self._get_current_ts() ) @@ -452,8 +445,7 @@ async def send(self, request: SynapseRequest, delay_id: str) -> None: NotFoundError: if no matching delayed event could be found. """ assert self._is_master - await self._mgmt_ratelimit(request) - await make_deferred_yieldable(self._initialized_from_db) + await self._mgmt_ratelimit(request, delay_id) event, next_send_ts = await self._store.process_target_delayed_event(delay_id) @@ -462,14 +454,24 @@ async def send(self, request: SynapseRequest, delay_id: str) -> None: await self._send_event(event) - async def _mgmt_ratelimit(self, request: SynapseRequest) -> None: + async def _mgmt_ratelimit(self, request: SynapseRequest, delay_id: str) -> None: + await make_deferred_yieldable(self._initialized_from_db) + + # NOTE: it would be more accurate to run this in a transaction that also contains + # the action to be done with the delay_id, but the worst that can happen without it + # is the possibility of the delay_id disappearing after having checked for it here, + # which isn't terrible since it's used here only for applying a ratelimit. + await self._store.has_delayed_event(delay_id) + if self._auth.has_access_token(request): requester = await self._auth.get_user_by_req(request) - key = None + user_id = requester.user.to_string() else: requester = None - key = request.getClientAddress().host - await self._delayed_event_mgmt_ratelimiter.ratelimit(requester, key) + user_id = None + await self._delayed_event_mgmt_ratelimiter.ratelimit( + requester, (user_id, delay_id) + ) async def _send_on_timeout(self) -> None: self._next_delayed_event_call = None diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index 1727f589e2a..aac4ac15722 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -210,6 +210,23 @@ def restart_delayed_event_txn( "restart_delayed_event", restart_delayed_event_txn ) + async def assert_delayed_event(self, delay_id: str) -> None: + """Checks whether there is a delayed event in the DB with the given delay_id. + + Raises: + NotFoundError: if there is no matching delayed event. + """ + + res = await self.db_pool.simple_select_one_onecol( + table="delayed_events", + keyvalues={"delay_id": delay_id}, + retcol="1", + allow_none=True, + desc="has_delayed_event", + ) + if not res: + raise NotFoundError("Delayed event not found") + async def get_count_of_delayed_events(self) -> int: """Returns the number of pending delayed events in the DB.""" From 410f4faf69ad94a8b8db7093a51fcf84e07e5f83 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 5 Jun 2026 12:14:03 -0400 Subject: [PATCH 06/14] Add changelog --- changelog.d/19830.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/19830.feature diff --git a/changelog.d/19830.feature b/changelog.d/19830.feature new file mode 100644 index 00000000000..3fb7545beda --- /dev/null +++ b/changelog.d/19830.feature @@ -0,0 +1 @@ +[MSC4140: Cancellable delayed events](https://github.com/matrix-org/matrix-spec-proposals/pull/4140): Rate limit the delayed event management endpoints based on the `delay_id`. From 0199f65628d592bec315c9a1525db4af796afdd7 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 5 Jun 2026 12:15:37 -0400 Subject: [PATCH 07/14] Fixup --- synapse/handlers/delayed_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py index dca2881a6bb..79a0491b564 100644 --- a/synapse/handlers/delayed_events.py +++ b/synapse/handlers/delayed_events.py @@ -461,7 +461,7 @@ async def _mgmt_ratelimit(self, request: SynapseRequest, delay_id: str) -> None: # the action to be done with the delay_id, but the worst that can happen without it # is the possibility of the delay_id disappearing after having checked for it here, # which isn't terrible since it's used here only for applying a ratelimit. - await self._store.has_delayed_event(delay_id) + await self._store.assert_delayed_event(delay_id) if self._auth.has_access_token(request): requester = await self._auth.get_user_by_req(request) From 5e0f44a45fac361a82f9ca078891e51da0071d1d Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 5 Jun 2026 15:05:16 -0400 Subject: [PATCH 08/14] Still use source IP address as ratelimit fallback For unauthenticated requests to delayed event management endpoints, use different rate limits for different source IP addresses, instead of having all such requests share the same limit (per delay_id). --- synapse/handlers/delayed_events.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py index 79a0491b564..e19bcffe94e 100644 --- a/synapse/handlers/delayed_events.py +++ b/synapse/handlers/delayed_events.py @@ -465,12 +465,12 @@ async def _mgmt_ratelimit(self, request: SynapseRequest, delay_id: str) -> None: if self._auth.has_access_token(request): requester = await self._auth.get_user_by_req(request) - user_id = requester.user.to_string() + requester_key = requester.user.to_string() else: requester = None - user_id = None + requester_key = request.getClientAddress().host await self._delayed_event_mgmt_ratelimiter.ratelimit( - requester, (user_id, delay_id) + requester, (requester_key, delay_id) ) async def _send_on_timeout(self) -> None: From f43bc622508a9e4ba722272fc9e0b6198b5f1f66 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 5 Jun 2026 15:10:36 -0400 Subject: [PATCH 09/14] Update tests --- tests/rest/client/test_delayed_events.py | 32 ++++++++++++++++-------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/tests/rest/client/test_delayed_events.py b/tests/rest/client/test_delayed_events.py index c3bfdf7c8d4..07a5d959e58 100644 --- a/tests/rest/client/test_delayed_events.py +++ b/tests/rest/client/test_delayed_events.py @@ -317,7 +317,7 @@ def test_cancel_delayed_state_event(self, action_in_path: bool) -> None: ) def test_cancel_delayed_event_ratelimit(self, action_in_path: bool) -> None: delay_ids = [] - for _ in range(3): + for _ in range(5): channel = self.make_request( "POST", _get_path_for_delayed_send(self.room_id, _EVENT_TYPE, 100000), @@ -329,11 +329,14 @@ def test_cancel_delayed_event_ratelimit(self, action_in_path: bool) -> None: assert delay_id is not None delay_ids.append(delay_id) - delay_id = delay_ids.pop(0) - channel = self._update_delayed_event(delay_id, "cancel", action_in_path) - self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + for _ in range(2): + delay_id = delay_ids.pop(0) + channel = self._update_delayed_event(delay_id, "cancel", action_in_path) + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) delay_id = delay_ids.pop(0) + channel = self._update_delayed_event(delay_id, "restart", action_in_path) + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) channel = self._update_delayed_event(delay_id, "cancel", action_in_path) self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) @@ -347,6 +350,16 @@ def test_cancel_delayed_event_ratelimit(self, action_in_path: bool) -> None: channel = self._update_delayed_event( delay_id, "cancel", action_in_path, self.user1_access_token ) + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + + delay_id = delay_ids.pop(0) + channel = self._update_delayed_event( + delay_id, "restart", action_in_path, self.user1_access_token + ) + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + channel = self._update_delayed_event( + delay_id, "cancel", action_in_path, self.user1_access_token + ) self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) # Add the current user to the ratelimit overrides, allowing them no ratelimiting. @@ -502,7 +515,7 @@ def test_restart_delayed_state_event(self, action_in_path: bool) -> None: ) def test_restart_delayed_event_ratelimit(self, action_in_path: bool) -> None: delay_ids = [] - for _ in range(3): + for _ in range(2): channel = self.make_request( "POST", _get_path_for_delayed_send(self.room_id, _EVENT_TYPE, 100000), @@ -514,11 +527,11 @@ def test_restart_delayed_event_ratelimit(self, action_in_path: bool) -> None: assert delay_id is not None delay_ids.append(delay_id) - delay_id = delay_ids.pop(0) - channel = self._update_delayed_event(delay_id, "restart", action_in_path) - self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + for _ in range(2): + delay_id = delay_ids.pop(0) + channel = self._update_delayed_event(delay_id, "restart", action_in_path) + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) - delay_id = delay_ids.pop(0) channel = self._update_delayed_event(delay_id, "restart", action_in_path) self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) @@ -528,7 +541,6 @@ def test_restart_delayed_event_ratelimit(self, action_in_path: bool) -> None: ) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) - delay_id = delay_ids.pop(0) channel = self._update_delayed_event( delay_id, "restart", action_in_path, self.user1_access_token ) From efe144c98e7724cf3748c79bf331c2804bff1ab0 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 5 Jun 2026 15:20:56 -0400 Subject: [PATCH 10/14] Satisfy mypy --- tests/rest/client/test_delayed_events.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/rest/client/test_delayed_events.py b/tests/rest/client/test_delayed_events.py index 07a5d959e58..7fda657291e 100644 --- a/tests/rest/client/test_delayed_events.py +++ b/tests/rest/client/test_delayed_events.py @@ -523,8 +523,8 @@ def test_restart_delayed_event_ratelimit(self, action_in_path: bool) -> None: self.user1_access_token, ) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) - delay_id = channel.json_body.get("delay_id") - assert delay_id is not None + assert "delay_id" in channel.json_body + delay_id = channel.json_body["delay_id"] delay_ids.append(delay_id) for _ in range(2): From 3745bd712649b18bc692c6f29e486da8c20ad195 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 10 Jun 2026 09:34:44 -0400 Subject: [PATCH 11/14] Revert always awaiting on _initialized_from_db Currently, it should be awaited only by the /cancel and /send delayed event management endpoints, but not /restart, which can be run by workers that do not set _initialized_from_db and thus can't await it. --- synapse/handlers/delayed_events.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py index e19bcffe94e..f3173a0e69f 100644 --- a/synapse/handlers/delayed_events.py +++ b/synapse/handlers/delayed_events.py @@ -414,6 +414,7 @@ async def cancel(self, request: SynapseRequest, delay_id: str) -> None: """ assert self._is_master await self._mgmt_ratelimit(request, delay_id) + await make_deferred_yieldable(self._initialized_from_db) next_send_ts = await self._store.cancel_delayed_event(delay_id) @@ -428,6 +429,12 @@ async def restart(self, request: SynapseRequest, delay_id: str) -> None: NotFoundError: if no matching delayed event could be found. """ await self._mgmt_ratelimit(request, delay_id) + + # Note: We don't need to wait on `self._initialized_from_db` here as the + # events that deals with are already marked as processed. + # + # `restart_delayed_events` will skip over such events entirely. + next_send_ts = await self._store.restart_delayed_event( delay_id, self._get_current_ts() ) @@ -446,6 +453,7 @@ async def send(self, request: SynapseRequest, delay_id: str) -> None: """ assert self._is_master await self._mgmt_ratelimit(request, delay_id) + await make_deferred_yieldable(self._initialized_from_db) event, next_send_ts = await self._store.process_target_delayed_event(delay_id) @@ -455,8 +463,6 @@ async def send(self, request: SynapseRequest, delay_id: str) -> None: await self._send_event(event) async def _mgmt_ratelimit(self, request: SynapseRequest, delay_id: str) -> None: - await make_deferred_yieldable(self._initialized_from_db) - # NOTE: it would be more accurate to run this in a transaction that also contains # the action to be done with the delay_id, but the worst that can happen without it # is the possibility of the delay_id disappearing after having checked for it here, From 0ad97efbfa4722b0078227eb0351173281a2fd36 Mon Sep 17 00:00:00 2001 From: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:58:03 +0000 Subject: [PATCH 12/14] Fix plural in comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- synapse/handlers/delayed_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py index 9880601c9ff..7950b431e5e 100644 --- a/synapse/handlers/delayed_events.py +++ b/synapse/handlers/delayed_events.py @@ -67,7 +67,7 @@ def __init__(self, hs: "HomeServer"): self._request_ratelimiter = hs.get_request_ratelimiter() - # Ratelimiters for management of existing delayed events + # Ratelimiter for management of existing delayed events self._delayed_event_mgmt_ratelimiter = Ratelimiter( store=self._store, clock=self._clock, From 863e58698a38475f612f195d31924988908e4a29 Mon Sep 17 00:00:00 2001 From: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:59:04 +0000 Subject: [PATCH 13/14] Minor wording changes Co-authored-by: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com> --- changelog.d/19794.feature | 2 +- schema/synapse-config.schema.yaml | 2 +- synapse/handlers/delayed_events.py | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/changelog.d/19794.feature b/changelog.d/19794.feature index d6504d3882c..be373f9a772 100644 --- a/changelog.d/19794.feature +++ b/changelog.d/19794.feature @@ -1 +1 @@ -[MSC4140: Cancellable delayed events](https://github.com/matrix-org/matrix-spec-proposals/pull/4140): Allow authentication on delayed event management endpoints (such as `/restart`) to bypass ratelimits based on the client IP address. +[MSC4140: Cancellable delayed events](https://github.com/matrix-org/matrix-spec-proposals/pull/4140): Allow authentication on delayed event management endpoints (such as `/restart`) to bypass ratelimits for unauthenticated requests based on the client IP address. diff --git a/schema/synapse-config.schema.yaml b/schema/synapse-config.schema.yaml index 60c4406b3c2..d7ad88b0fc3 100644 --- a/schema/synapse-config.schema.yaml +++ b/schema/synapse-config.schema.yaml @@ -2245,7 +2245,7 @@ properties: This is a ratelimiting option that ratelimits attempts to restart, cancel, or view delayed events based on the sending client's account, - or its source IP when unauthenticated. + or its source IP when requests are unauthenticated. Attempts to create or send delayed events are ratelimited not by this diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py index 7950b431e5e..6afb7c0f3c4 100644 --- a/synapse/handlers/delayed_events.py +++ b/synapse/handlers/delayed_events.py @@ -463,6 +463,10 @@ async def send(self, request: SynapseRequest, delay_id: str) -> None: await self._send_event(event) async def _mgmt_ratelimit(self, request: SynapseRequest) -> None: + """ + Ratelimit requests with the `_delayed_event_mgmt_ratelimiter` keyed on the + user making the request, or the request's IP address if unauthed. + """ if self._auth.has_access_token(request): requester = await self._auth.get_user_by_req(request) key = None From c17cdf13a45afb1325a5f70e5631f2020a832b77 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 18 Jun 2026 08:51:38 -0400 Subject: [PATCH 14/14] Update generated documentation --- docs/usage/configuration/config_documentation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage/configuration/config_documentation.md b/docs/usage/configuration/config_documentation.md index 4338aa9d82f..d1a8a064c92 100644 --- a/docs/usage/configuration/config_documentation.md +++ b/docs/usage/configuration/config_documentation.md @@ -1971,7 +1971,7 @@ rc_presence: *(object)* Ratelimiting settings for delayed event management. -This is a ratelimiting option that ratelimits attempts to restart, cancel, or view delayed events based on the sending client's account, or its source IP when unauthenticated. +This is a ratelimiting option that ratelimits attempts to restart, cancel, or view delayed events based on the sending client's account, or its source IP when requests are unauthenticated. Attempts to create or send delayed events are ratelimited not by this setting, but by `rc_message`.