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
+
+