fix: rate-limit pairing rejection messages to prevent spam (#4081)
* 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>
This commit is contained in:
@@ -1702,6 +1702,11 @@ class GatewayRunner:
|
|||||||
# In DMs: offer pairing code. In groups: silently ignore.
|
# In DMs: offer pairing code. In groups: silently ignore.
|
||||||
if source.chat_type == "dm" and self._get_unauthorized_dm_behavior(source.platform) == "pair":
|
if source.chat_type == "dm" and self._get_unauthorized_dm_behavior(source.platform) == "pair":
|
||||||
platform_name = source.platform.value if source.platform else "unknown"
|
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(
|
code = self.pairing_store.generate_code(
|
||||||
platform_name, source.user_id, source.user_name or ""
|
platform_name, source.user_id, source.user_name or ""
|
||||||
)
|
)
|
||||||
@@ -1723,6 +1728,8 @@ class GatewayRunner:
|
|||||||
"Too many pairing requests right now~ "
|
"Too many pairing requests right now~ "
|
||||||
"Please try again later!"
|
"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
|
return None
|
||||||
|
|
||||||
# PRIORITY handling when an agent is already running for this session.
|
# PRIORITY handling when an agent is already running for this session.
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ def _make_runner(platform: Platform, config: GatewayConfig):
|
|||||||
runner.adapters = {platform: adapter}
|
runner.adapters = {platform: adapter}
|
||||||
runner.pairing_store = MagicMock()
|
runner.pairing_store = MagicMock()
|
||||||
runner.pairing_store.is_approved.return_value = False
|
runner.pairing_store.is_approved.return_value = False
|
||||||
|
runner.pairing_store._is_rate_limited.return_value = False
|
||||||
return runner, adapter
|
return runner, adapter
|
||||||
|
|
||||||
|
|
||||||
@@ -142,6 +143,56 @@ async def test_unauthorized_whatsapp_dm_can_be_ignored(monkeypatch):
|
|||||||
adapter.send.assert_not_awaited()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_global_ignore_suppresses_pairing_reply(monkeypatch):
|
async def test_global_ignore_suppresses_pairing_reply(monkeypatch):
|
||||||
_clear_auth_env(monkeypatch)
|
_clear_auth_env(monkeypatch)
|
||||||
|
|||||||
Reference in New Issue
Block a user