From f007284d051900a424745dc4d4fb4bdcd78eff04 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:48:00 -0700 Subject: [PATCH] fix: rate-limit pairing rejection messages to prevent spam (#4081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: rate-limit pairing rejection messages to prevent spam When generate_code() returns None (rate limited or max pending), the "Too many pairing requests" message was sent on every subsequent DM with no cooldown. A user sending 30 messages would get 30 rejection replies — reported as potential hack on WhatsApp. Now check _is_rate_limited() before any pairing response, and record rate limit after sending a rejection. Subsequent messages from the same user are silently ignored until the rate limit window expires. * test: add coverage for pairing response rate limiting Follow-up to cherry-picked PR #4042 — adds tests verifying: - Rate-limited users get silently ignored (no response sent) - Rejection messages record rate limit for subsequent suppression --------- Co-authored-by: 0xbyt4 <35742124+0xbyt4@users.noreply.github.com> --- gateway/run.py | 7 +++ .../gateway/test_unauthorized_dm_behavior.py | 51 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 7638d8a51..735832744 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1702,6 +1702,11 @@ class GatewayRunner: # In DMs: offer pairing code. In groups: silently ignore. if source.chat_type == "dm" and self._get_unauthorized_dm_behavior(source.platform) == "pair": platform_name = source.platform.value if source.platform else "unknown" + # Rate-limit ALL pairing responses (code or rejection) to + # prevent spamming the user with repeated messages when + # multiple DMs arrive in quick succession. + if self.pairing_store._is_rate_limited(platform_name, source.user_id): + return None code = self.pairing_store.generate_code( platform_name, source.user_id, source.user_name or "" ) @@ -1723,6 +1728,8 @@ class GatewayRunner: "Too many pairing requests right now~ " "Please try again later!" ) + # Record rate limit so subsequent messages are silently ignored + self.pairing_store._record_rate_limit(platform_name, source.user_id) return None # PRIORITY handling when an agent is already running for this session. diff --git a/tests/gateway/test_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py index 02aae301c..25b51dc2f 100644 --- a/tests/gateway/test_unauthorized_dm_behavior.py +++ b/tests/gateway/test_unauthorized_dm_behavior.py @@ -60,6 +60,7 @@ def _make_runner(platform: Platform, config: GatewayConfig): runner.adapters = {platform: adapter} runner.pairing_store = MagicMock() runner.pairing_store.is_approved.return_value = False + runner.pairing_store._is_rate_limited.return_value = False return runner, adapter @@ -142,6 +143,56 @@ async def test_unauthorized_whatsapp_dm_can_be_ignored(monkeypatch): adapter.send.assert_not_awaited() +@pytest.mark.asyncio +async def test_rate_limited_user_gets_no_response(monkeypatch): + """When a user is already rate-limited, pairing messages are silently ignored.""" + _clear_auth_env(monkeypatch) + config = GatewayConfig( + platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)}, + ) + runner, adapter = _make_runner(Platform.WHATSAPP, config) + runner.pairing_store._is_rate_limited.return_value = True + + result = await runner._handle_message( + _make_event( + Platform.WHATSAPP, + "15551234567@s.whatsapp.net", + "15551234567@s.whatsapp.net", + ) + ) + + assert result is None + runner.pairing_store.generate_code.assert_not_called() + adapter.send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_rejection_message_records_rate_limit(monkeypatch): + """After sending a 'too many requests' rejection, rate limit is recorded + so subsequent messages are silently ignored.""" + _clear_auth_env(monkeypatch) + config = GatewayConfig( + platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)}, + ) + runner, adapter = _make_runner(Platform.WHATSAPP, config) + runner.pairing_store.generate_code.return_value = None # triggers rejection + + result = await runner._handle_message( + _make_event( + Platform.WHATSAPP, + "15551234567@s.whatsapp.net", + "15551234567@s.whatsapp.net", + ) + ) + + assert result is None + adapter.send.assert_awaited_once() + assert "Too many" in adapter.send.await_args.args[1] + runner.pairing_store._record_rate_limit.assert_called_once_with( + "whatsapp", "15551234567@s.whatsapp.net" + ) + + @pytest.mark.asyncio async def test_global_ignore_suppresses_pairing_reply(monkeypatch): _clear_auth_env(monkeypatch)