From f96b2e6ef75ba6ed678c99954bc8f3ee7f6a38ba Mon Sep 17 00:00:00 2001 From: SahilRakhaiya05 <144577420+SahilRakhaiya05@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:49:38 +0530 Subject: [PATCH] fix(whatsapp_cloud): gate interactive taps on DM allowlist --- gateway/platforms/whatsapp_cloud.py | 26 ++++++++++ tests/gateway/test_whatsapp_cloud.py | 71 ++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/gateway/platforms/whatsapp_cloud.py b/gateway/platforms/whatsapp_cloud.py index c97122ede1d2..bd26252f1272 100644 --- a/gateway/platforms/whatsapp_cloud.py +++ b/gateway/platforms/whatsapp_cloud.py @@ -387,6 +387,20 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): return True return super()._open_dm_opted_in() + def _is_interactive_sender_authorized(self, sender_id: str) -> bool: + """Authorize inbound button/list taps before running resolvers. + + Interactive replies bypass the normal ``_build_message_event_from_cloud`` + path (which calls ``_should_process_message``), so approval / + slash-confirm / clarify taps must re-check DM policy here. Uses the + strict ``_is_dm_allowed`` gate (not intake/pairing) so a stale prompt + cannot be answered after the sender is removed from the allowlist. + """ + principal = str(sender_id or "").strip() + if not principal: + return False + return self._is_dm_allowed(principal) + # ------------------------------------------------------------------ lifecycle async def connect(self, *, is_reconnect: bool = False) -> bool: if not check_whatsapp_cloud_requirements(): @@ -1635,6 +1649,18 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if not button_id: return False + sender_id = str(raw_message.get("from") or "").strip() + if not self._is_interactive_sender_authorized(sender_id): + logger.warning( + "[whatsapp_cloud] Rejected unauthorized interactive tap " + "from %s (button_id=%r)", + sender_id or "", + button_id, + ) + # Claim the webhook entry so the tap is not re-dispatched as + # plain text (which could re-enter the agent loop). + return True + # Clarify: cl:: if button_id.startswith("cl:"): parts = button_id.split(":", 2) diff --git a/tests/gateway/test_whatsapp_cloud.py b/tests/gateway/test_whatsapp_cloud.py index c24e9796e0ea..600f4f40a52b 100644 --- a/tests/gateway/test_whatsapp_cloud.py +++ b/tests/gateway/test_whatsapp_cloud.py @@ -110,6 +110,12 @@ def _make_adapter(**overrides): return adapter +@pytest.fixture +def authorized_interactive_env(monkeypatch): + """``dm_policy: open`` requires an explicit allow-all opt-in on main.""" + monkeypatch.setenv("WHATSAPP_ALLOW_ALL_USERS", "true") + + def _mock_httpx_response(status_code: int, json_body: dict): """Build an httpx-Response-like mock the adapter's ``send`` will accept.""" resp = MagicMock() @@ -1790,6 +1796,7 @@ class TestSendSlashConfirmButtons: assert adapter._slash_confirm_state["cf-9"] == "sess-sc-1" +@pytest.mark.usefixtures("authorized_interactive_env") class TestDispatchInteractiveReplyClarify: """Inbound side: button-tap → clarify resolver.""" @@ -1930,6 +1937,7 @@ class TestDispatchInteractiveReplyClarify: assert handled is False +@pytest.mark.usefixtures("authorized_interactive_env") class TestDispatchInteractiveReplyApproval: """Inbound side: approval-tap → resolve_gateway_approval.""" @@ -1995,6 +2003,7 @@ class TestDispatchInteractiveReplyApproval: assert "Denied" in confirm_payload["text"]["body"] +@pytest.mark.usefixtures("authorized_interactive_env") class TestDispatchInteractiveReplySlashConfirm: """Inbound side: slash-confirm-tap → tools.slash_confirm.resolve.""" @@ -2038,6 +2047,68 @@ class TestDispatchInteractiveReplySlashConfirm: assert "MCP reloaded" in reply_payload["text"]["body"] +class TestDispatchInteractiveReplyAuthorization: + """Interactive taps must honor the same DM allowlist as text intake.""" + + @pytest.mark.asyncio + async def test_approval_tap_denied_when_sender_not_allowlisted(self, monkeypatch): + adapter = _make_adapter( + _dm_policy="allowlist", + _allow_from={"19998887777"}, + ) + adapter._exec_approval_state["app1"] = "sess-app-1" + calls = [] + monkeypatch.setattr( + "tools.approval.resolve_gateway_approval", + lambda session_key, choice: calls.append((session_key, choice)) or 1, + ) + + raw = { + "from": "15551234567", + "type": "interactive", + "interactive": { + "type": "button_reply", + "button_reply": {"id": "appr:app1:approve", "title": "Approve"}, + }, + } + handled = await adapter._dispatch_interactive_reply(raw, {}) + + assert handled is True + assert calls == [] + assert adapter._exec_approval_state["app1"] == "sess-app-1" + + @pytest.mark.asyncio + async def test_approval_tap_allowed_when_sender_allowlisted(self, monkeypatch): + adapter = _make_adapter( + _dm_policy="allowlist", + _allow_from={"15551234567"}, + ) + adapter._exec_approval_state["app1"] = "sess-app-1" + adapter._http_client = MagicMock() + adapter._http_client.post = AsyncMock( + return_value=_mock_httpx_response(200, {"messages": [{"id": "x"}]}) + ) + calls = [] + monkeypatch.setattr( + "tools.approval.resolve_gateway_approval", + lambda session_key, choice: calls.append((session_key, choice)) or 1, + ) + + raw = { + "from": "15551234567", + "type": "interactive", + "interactive": { + "type": "button_reply", + "button_reply": {"id": "appr:app1:approve", "title": "Approve"}, + }, + } + handled = await adapter._dispatch_interactive_reply(raw, {}) + + assert handled is True + assert calls == [("sess-app-1", "approve")] + + +@pytest.mark.usefixtures("authorized_interactive_env") class TestInteractiveReplyEndToEnd: """Integration: `_build_message_event_from_cloud` must SHORT-CIRCUIT on a recognized interactive reply and NOT also produce a fresh