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