From a26680eb2d9eee75c183d9ebe1648519a6f31aea Mon Sep 17 00:00:00 2001 From: luyifan Date: Mon, 29 Jun 2026 22:30:35 +0800 Subject: [PATCH] Enforce Feishu webhook body limit while reading --- plugins/platforms/feishu/adapter.py | 27 +++++++++++++---- tests/gateway/test_feishu.py | 46 +++++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index 093828d7aff..90cea13f646 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -228,6 +228,19 @@ _APPROVAL_LABEL_MAP: Dict[str, str] = { "always": "Approved permanently", "deny": "Denied", } + + +async def _read_limited_feishu_webhook_body(request: Any, max_bytes: int) -> bytes: + """Read at most ``max_bytes`` from an aiohttp request body.""" + try: + body = await request.content.readexactly(max_bytes + 1) + except asyncio.IncompleteReadError as exc: + body = exc.partial + if len(body) > max_bytes: + raise ValueError("payload too large") + return body + + _FEISHU_BOT_MSG_TRACK_SIZE = 512 # LRU size for tracking sent message IDs _FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003}) # reply target withdrawn/missing → create fallback @@ -3467,9 +3480,16 @@ class FeishuAdapter(BasePlatformAdapter): try: body_bytes: bytes = await asyncio.wait_for( - request.read(), + _read_limited_feishu_webhook_body( + request, + _FEISHU_WEBHOOK_MAX_BODY_BYTES, + ), timeout=_FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS, ) + except ValueError: + logger.warning("[Feishu] Webhook body exceeds limit from %s", remote_ip) + self._record_webhook_anomaly(remote_ip, "413") + return web.Response(status=413, text="Request body too large") except asyncio.TimeoutError: logger.warning("[Feishu] Webhook body read timed out after %ds from %s", _FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS, remote_ip) self._record_webhook_anomaly(remote_ip, "408") @@ -3478,11 +3498,6 @@ class FeishuAdapter(BasePlatformAdapter): self._record_webhook_anomaly(remote_ip, "400") return web.json_response({"code": 400, "msg": "failed to read body"}, status=400) - if len(body_bytes) > _FEISHU_WEBHOOK_MAX_BODY_BYTES: - logger.warning("[Feishu] Webhook body exceeds limit (%d bytes) from %s", len(body_bytes), remote_ip) - self._record_webhook_anomaly(remote_ip, "413") - return web.Response(status=413, text="Request body too large") - try: payload = json.loads(body_bytes.decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError): diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index af868dd8405..1d95008d9f4 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -21,6 +21,18 @@ except ImportError: _HAS_LARK_OAPI = False +class _FakeRequestContent: + def __init__(self, body: bytes): + self.body = body + self.read_sizes: list[int] = [] + + async def readexactly(self, size: int) -> bytes: + self.read_sizes.append(size) + if len(self.body) < size: + raise asyncio.IncompleteReadError(self.body, size) + return self.body[:size] + + def _mock_event_dispatcher_builder(mock_handler_class): mock_builder = Mock() mock_builder.register_p2_im_message_message_read_v1 = Mock(return_value=mock_builder) @@ -1604,7 +1616,7 @@ class TestAdapterBehavior(unittest.TestCase): remote="127.0.0.1", content_length=None, headers={}, - read=AsyncMock(return_value=body), + content=_FakeRequestContent(body), ) response = asyncio.run(adapter._handle_webhook_request(request)) @@ -1633,7 +1645,7 @@ class TestAdapterBehavior(unittest.TestCase): remote="203.0.113.10", content_length=None, headers={}, - read=AsyncMock(return_value=body), + content=_FakeRequestContent(body), ) response = asyncio.run(adapter._handle_webhook_request(request)) @@ -3261,6 +3273,30 @@ class TestWebhookSecurity(unittest.TestCase): response = asyncio.run(adapter._handle_webhook_request(request)) self.assertEqual(response.status, 413) + def test_webhook_request_rejects_oversized_chunked_body_while_reading(self): + from gateway.config import PlatformConfig + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + from plugins.platforms.feishu.adapter import FeishuAdapter, _FEISHU_WEBHOOK_MAX_BODY_BYTES + + with tempfile.TemporaryDirectory() as tmpdir: + token = set_hermes_home_override(tmpdir) + try: + adapter = FeishuAdapter(PlatformConfig()) + finally: + reset_hermes_home_override(token) + content = _FakeRequestContent(b"A" * (_FEISHU_WEBHOOK_MAX_BODY_BYTES + 2)) + request = SimpleNamespace( + remote="127.0.0.1", + content_length=None, + headers={}, + content=content, + ) + + response = asyncio.run(adapter._handle_webhook_request(request)) + + self.assertEqual(response.status, 413) + self.assertEqual(content.read_sizes, [_FEISHU_WEBHOOK_MAX_BODY_BYTES + 1]) + @patch.dict(os.environ, {}, clear=True) def test_webhook_request_rejects_invalid_json(self): from gateway.config import PlatformConfig @@ -3270,7 +3306,7 @@ class TestWebhookSecurity(unittest.TestCase): request = SimpleNamespace( remote="127.0.0.1", content_length=None, - read=AsyncMock(return_value=b"not-json"), + content=_FakeRequestContent(b"not-json"), ) response = asyncio.run(adapter._handle_webhook_request(request)) self.assertEqual(response.status, 400) @@ -3286,7 +3322,7 @@ class TestWebhookSecurity(unittest.TestCase): remote="127.0.0.1", content_length=None, headers={"x-lark-request-timestamp": "123", "x-lark-request-nonce": "abc", "x-lark-signature": "bad"}, - read=AsyncMock(return_value=body), + content=_FakeRequestContent(body), ) response = asyncio.run(adapter._handle_webhook_request(request)) self.assertEqual(response.status, 401) @@ -3335,7 +3371,7 @@ class TestWebhookSecurity(unittest.TestCase): request = SimpleNamespace( remote="127.0.0.1", content_length=None, - read=AsyncMock(return_value=body), + content=_FakeRequestContent(body), ) response = asyncio.run(adapter._handle_webhook_request(request)) self.assertEqual(response.status, 200)