fix(security): cap WeCom callback body size before pre-auth XML parse (#54615)

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).
This commit is contained in:
Teknium 2026-06-28 22:35:43 -07:00 committed by GitHub
parent 0b733a8418
commit 74541beb9c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 55 additions and 2 deletions

View file

@ -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: