diff --git a/plugins/platforms/wecom/callback_adapter.py b/plugins/platforms/wecom/callback_adapter.py index 496c789e4e0..e7e7931b7b8 100644 --- a/plugins/platforms/wecom/callback_adapter.py +++ b/plugins/platforms/wecom/callback_adapter.py @@ -54,6 +54,11 @@ logger = logging.getLogger(__name__) DEFAULT_HOST = "0.0.0.0" DEFAULT_PORT = 8645 DEFAULT_PATH = "/wecom/callback" +# Cap pre-auth request bodies. WeCom callbacks are small encrypted XML +# envelopes (media is delivered out-of-band via MediaId, never inline), so +# 64 KB is ample for any legitimate message while bounding the work an +# unauthenticated POST can force before signature verification. +_MAX_BODY = 65_536 ACCESS_TOKEN_TTL_SECONDS = 7200 MESSAGE_DEDUP_TTL_SECONDS = 300 @@ -132,7 +137,9 @@ class WecomCallbackAdapter(BasePlatformAdapter): # Tighter keepalive so idle CLOSE_WAIT drains promptly (#18451). from gateway.platforms._http_client_limits import platform_httpx_limits self._http_client = httpx.AsyncClient(timeout=20.0, limits=platform_httpx_limits()) - self._app = web.Application() + # client_max_size rejects oversized bodies at the aiohttp layer + # (413) before our handler — and before any signature work — runs. + self._app = web.Application(client_max_size=_MAX_BODY) self._app.router.add_get("/health", self._handle_health) self._app.router.add_get(self._path, self._handle_verify) self._app.router.add_post(self._path, self._handle_callback) @@ -273,7 +280,13 @@ class WecomCallbackAdapter(BasePlatformAdapter): msg_signature = request.query.get("msg_signature", "") timestamp = request.query.get("timestamp", "") nonce = request.query.get("nonce", "") - body = await request.text() + # Explicit guard in addition to client_max_size: rejects oversized + # payloads before any XML parse / signature check (DoS, zip bombs). + body_bytes = await request.read() + if len(body_bytes) > _MAX_BODY: + logger.warning("[WecomCallback] Payload too large (%d bytes) — rejected", len(body_bytes)) + return web.Response(status=413, text="payload too large") + body = body_bytes.decode("utf-8", errors="replace") for app in self._apps: try: diff --git a/tests/gateway/test_wecom_callback.py b/tests/gateway/test_wecom_callback.py index d41131f432d..467ace7d3df 100644 --- a/tests/gateway/test_wecom_callback.py +++ b/tests/gateway/test_wecom_callback.py @@ -307,3 +307,43 @@ class TestWecomCallbackPollLoop: with pytest.raises(asyncio.CancelledError): await task assert calls == ["test"] + + +class TestWecomCallbackBodySizeLimit: + """Pre-auth oversized-body rejection (DoS hardening, PR #10192).""" + + def _request(self, body_bytes): + from unittest.mock import Mock + + from aiohttp import StreamReader + from aiohttp.test_utils import make_mocked_request + + protocol = Mock(_reading_paused=False) + reader = StreamReader(protocol=protocol, limit=2 ** 20) + reader.feed_data(body_bytes) + reader.feed_eof() + return make_mocked_request( + "POST", "/wecom/callback?msg_signature=s×tamp=1&nonce=n", + payload=reader, + ) + + @pytest.mark.asyncio + async def test_oversized_body_rejected_with_413(self): + from plugins.platforms.wecom.callback_adapter import _MAX_BODY + + adapter = WecomCallbackAdapter(_config()) + oversized = b"" + b"A" * (_MAX_BODY + 1) + b"" + response = await adapter._handle_callback(self._request(oversized)) + assert response.status == 413 + + @pytest.mark.asyncio + async def test_normal_sized_body_not_rejected_for_size(self): + adapter = WecomCallbackAdapter(_config()) + # A small body passes the size guard and proceeds to decrypt, which + # fails signature verification (400), NOT 413 — proving the guard + # doesn't reject legitimate-sized payloads. + small = b"not-real" + response = await adapter._handle_callback(self._request(small)) + assert response.status != 413 + +