diff --git a/gateway/platforms/msgraph_webhook.py b/gateway/platforms/msgraph_webhook.py index 04058a41a..ab0249a40 100644 --- a/gateway/platforms/msgraph_webhook.py +++ b/gateway/platforms/msgraph_webhook.py @@ -358,7 +358,9 @@ class MSGraphWebhookAdapter(BasePlatformAdapter): provided = self._string_or_none(notification.get("clientState")) if provided is None: return False - return hmac.compare_digest(provided, expected) + # Compare as bytes: ``compare_digest`` raises TypeError on a str with + # non-ASCII characters, and clientState comes from the request body. + return hmac.compare_digest(provided.encode(), expected.encode()) def _has_seen_receipt(self, receipt_key: str) -> bool: return receipt_key in self._seen_receipts diff --git a/gateway/platforms/whatsapp_cloud.py b/gateway/platforms/whatsapp_cloud.py index b494fc15e..91f6e699b 100644 --- a/gateway/platforms/whatsapp_cloud.py +++ b/gateway/platforms/whatsapp_cloud.py @@ -1413,10 +1413,11 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): return web.Response(status=400, text="bad mode") # Constant-time compare to avoid token-length / token-content leaks - # via timing. ``hmac.compare_digest`` works on str. + # via timing. Compare as bytes: ``compare_digest`` raises TypeError on + # a str with non-ASCII characters, and the token is a raw query param. import hmac as _hmac - if not _hmac.compare_digest(token, self._verify_token): + if not _hmac.compare_digest(token.encode(), self._verify_token.encode()): return web.Response(status=403, text="verify_token mismatch") if not challenge: return web.Response(status=400, text="missing challenge") @@ -1509,7 +1510,11 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): raw_body, hashlib.sha256, ).hexdigest() - return hmac.compare_digest(computed.lower(), expected_hex.lower()) + # Compare as bytes: compare_digest raises TypeError on a str with + # non-ASCII characters, and the signature is a raw request header. + return hmac.compare_digest( + computed.lower().encode(), expected_hex.lower().encode() + ) # ------------------------------------------------------------------ dispatch def _dedup_wamid(self, wamid: str) -> bool: diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index 41e087069..965286469 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -3512,7 +3512,11 @@ class FeishuAdapter(BasePlatformAdapter): if self._verification_token: header = payload.get("header") or {} incoming_token = str(header.get("token") or payload.get("token") or "") - if not incoming_token or not hmac.compare_digest(incoming_token, self._verification_token): + # Compare as bytes: compare_digest raises TypeError on a str with + # non-ASCII characters, and the token comes from the request body. + if not incoming_token or not hmac.compare_digest( + incoming_token.encode(), self._verification_token.encode() + ): logger.warning("[Feishu] Webhook rejected: invalid verification token from %s", remote_ip) self._record_webhook_anomaly(remote_ip, "401-token") return web.Response(status=401, text="Invalid verification token") @@ -3575,7 +3579,9 @@ class FeishuAdapter(BasePlatformAdapter): body_str = body_bytes.decode("utf-8", errors="replace") content = f"{timestamp}{nonce}{self._encrypt_key}{body_str}" computed = hashlib.sha256(content.encode("utf-8")).hexdigest() - return hmac.compare_digest(computed, signature) + # Compare as bytes: compare_digest raises TypeError on a str with + # non-ASCII characters, and the signature is a raw request header. + return hmac.compare_digest(computed.encode(), signature.encode()) except Exception: logger.debug("[Feishu] Signature verification raised an exception", exc_info=True) return False diff --git a/plugins/platforms/line/adapter.py b/plugins/platforms/line/adapter.py index 447cf5fb0..63343d37d 100644 --- a/plugins/platforms/line/adapter.py +++ b/plugins/platforms/line/adapter.py @@ -273,7 +273,9 @@ def verify_line_signature(body: bytes, signature: str, channel_secret: str) -> b expected = base64.b64encode(digest).decode("utf-8") except Exception: return False - return hmac.compare_digest(expected, signature) + # Compare as bytes: compare_digest raises TypeError on a str with + # non-ASCII characters, and the signature is a raw request header. + return hmac.compare_digest(expected.encode(), signature.encode()) # --------------------------------------------------------------------------- diff --git a/plugins/platforms/raft/adapter.py b/plugins/platforms/raft/adapter.py index 3d632451c..7e0494fa9 100644 --- a/plugins/platforms/raft/adapter.py +++ b/plugins/platforms/raft/adapter.py @@ -692,7 +692,9 @@ class RaftAdapter(BasePlatformAdapter): def _validate_bridge_token(self, token: str) -> bool: if not self._bridge_token or not token: return False - return hmac.compare_digest(token, self._bridge_token) + # Compare as bytes: compare_digest raises TypeError on a str with + # non-ASCII characters, and the token is a raw request header. + return hmac.compare_digest(token.encode(), self._bridge_token.encode()) async def _accept_wake(self, payload: Dict[str, Any]) -> bool: if not self._message_handler: diff --git a/plugins/platforms/sms/adapter.py b/plugins/platforms/sms/adapter.py index 04bf8e2a8..3d794da59 100644 --- a/plugins/platforms/sms/adapter.py +++ b/plugins/platforms/sms/adapter.py @@ -257,7 +257,9 @@ class SmsAdapter(BasePlatformAdapter): hashlib.sha1, ) computed = base64.b64encode(mac.digest()).decode("utf-8") - return hmac.compare_digest(computed, signature) + # Compare as bytes: compare_digest raises TypeError on a str with + # non-ASCII characters, and the signature is a raw request header. + return hmac.compare_digest(computed.encode(), signature.encode()) @staticmethod def _port_variant_url(url: str) -> str | None: diff --git a/tests/gateway/test_msgraph_webhook.py b/tests/gateway/test_msgraph_webhook.py index ca9d603c6..8ed0c21c2 100644 --- a/tests/gateway/test_msgraph_webhook.py +++ b/tests/gateway/test_msgraph_webhook.py @@ -316,8 +316,30 @@ class TestMSGraphNotifications: assert calls, "hmac.compare_digest was never called; clientState check is not timing-safe" provided, expected = calls[0] - assert provided == "expected-client-state" - assert expected == "expected-client-state" + assert provided == b"expected-client-state" + assert expected == b"expected-client-state" + + @pytest.mark.anyio + async def test_non_ascii_client_state_rejected_without_raising(self): + """A non-ASCII clientState (attacker-controlled request body) must be + rejected with 403, not crash the handler: hmac.compare_digest raises + TypeError on a str containing non-ASCII characters.""" + adapter = _make_adapter() + payload = { + "value": [ + { + "id": "notif-nonascii", + "subscriptionId": "sub-1", + "changeType": "updated", + "resource": "communications/onlineMeetings/meeting-x", + "clientState": "ské-not-the-secret", + } + ] + } + response = await adapter._handle_notification( + _FakeRequest(json_payload=payload) + ) + assert response.status == 403 @pytest.mark.anyio async def test_duplicate_notification_deduped(self): diff --git a/tests/gateway/test_whatsapp_cloud.py b/tests/gateway/test_whatsapp_cloud.py index 600f4f40a..dcd4ba559 100644 --- a/tests/gateway/test_whatsapp_cloud.py +++ b/tests/gateway/test_whatsapp_cloud.py @@ -353,6 +353,22 @@ class TestWebhookVerify: assert response.status == 403 + @pytest.mark.asyncio + async def test_verify_rejects_non_ascii_token_without_raising(self): + """A non-ASCII verify_token (raw query param) must be rejected with + 403, not crash the handler: hmac.compare_digest raises TypeError on a + str containing non-ASCII characters.""" + adapter = _make_adapter(verify_token="shared-secret-123") + request = _verify_request({ + "hub.mode": "subscribe", + "hub.verify_token": "ské-not-the-secret", + "hub.challenge": "abc-12345", + }) + + response = await adapter._handle_verify(request) + + assert response.status == 403 + @pytest.mark.asyncio async def test_verify_rejects_wrong_mode(self): adapter = _make_adapter(verify_token="shared-secret-123") diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index cf76929c6..650ee5b31 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -596,7 +596,10 @@ def _rpc_server_loop( continue if not rpc_token or not secrets.compare_digest( - str(request.get("token") or ""), rpc_token + # Compare as bytes: compare_digest raises TypeError on a + # str with non-ASCII characters, and the token comes from + # sandbox-script-supplied JSON. + str(request.get("token") or "").encode(), rpc_token.encode() ): resp = json.dumps({"error": "Unauthorized RPC request"}) conn.sendall((resp + "\n").encode()) @@ -881,7 +884,10 @@ def _rpc_poll_loop( continue if not rpc_token or not secrets.compare_digest( - str(request.get("token") or ""), rpc_token + # Compare as bytes: compare_digest raises TypeError on a + # str with non-ASCII characters, and the token comes from + # sandbox-script-supplied JSON. + str(request.get("token") or "").encode(), rpc_token.encode() ): logger.debug("Unauthorized RPC request in %s", req_file) env.execute(f"rm -f {quoted_req_file}", cwd="/", timeout=5)