Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions plugins/platforms/wecom/callback_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
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

Expand Down Expand Up @@ -132,7 +137,9 @@ async def connect(self) -> bool:
# 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)
Expand Down Expand Up @@ -273,7 +280,13 @@ async def _handle_callback(self, request: web.Request) -> web.Response:
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:
Expand Down
40 changes: 40 additions & 0 deletions tests/gateway/test_wecom_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,43 @@ async def fake_handle_message(event):
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&timestamp=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"<xml>" + b"A" * (_MAX_BODY + 1) + b"</xml>"
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"<xml><Encrypt>not-real</Encrypt></xml>"
response = await adapter._handle_callback(self._request(small))
assert response.status != 413


Loading