From 74541beb9ce32fcb1b8d89d698120d4b59d34ca8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:35:43 -0700 Subject: [PATCH] fix(security): cap WeCom callback body size before pre-auth XML parse (#54615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WeCom callback endpoint (internet-facing, 0.0.0.0) parsed untrusted request bodies before signature verification. defusedxml already guards the entity-expansion class on main, but there was no cap on raw body size, so an unauthenticated POST could still force unbounded read work pre-auth. Set client_max_size=64KB on the aiohttp app (413 at the framework layer) plus an explicit length guard in _handle_callback as defense in depth. WeCom callbacks are small encrypted XML envelopes — media is delivered out-of-band via MediaId, never inline — so 64KB is ample for legitimate traffic. Adds tests for oversized (413) and normal-sized (not 413) bodies. Salvaged from #10192 by @memosr (body-size limit half; defusedxml half already superseded on main). --- plugins/platforms/wecom/callback_adapter.py | 17 +++++++-- tests/gateway/test_wecom_callback.py | 40 +++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) 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 + +