From 8986981df4e55003e7b5aa6e14a2f1c8826550bc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:48:28 -0700 Subject: [PATCH] security(gateway): set explicit client_max_size on 3 uncapped aiohttp servers (#59180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling sweep from the #58902 raft review found aiohttp servers still running on the implicit 1 MiB default with no explicit body cap: - bluebubbles webhook (127.0.0.1): 1 MiB explicit cap — events are small JSON/form payloads; attachments arrive via the REST API - teams Bot Framework listener (0.0.0.0 bind — most exposed): 1 MiB cap; activities are JSON well under that - hermes proxy server: 10 MB cap mirroring api_server's MAX_REQUEST_BYTES (chat-completion payloads can be large, but must stay bounded) client_max_size bounds every read path including chunked transfer-encoding requests that carry no Content-Length (#58536/#58902 pattern). Deliberately excluded: feishu, whatsapp_cloud, sms, line, wecom, msgraph — open contributor PRs (#54938, #54944, #54620, #54931, #54934, #25296) already cover those; reviewing them separately preserves their credit. 3 regression tests pin the wiring. --- gateway/platforms/bluebubbles.py | 10 ++++++- hermes_cli/proxy/server.py | 6 ++++- plugins/platforms/teams/adapter.py | 11 ++++++-- tests/gateway/test_aiohttp_body_caps.py | 36 +++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 tests/gateway/test_aiohttp_body_caps.py diff --git a/gateway/platforms/bluebubbles.py b/gateway/platforms/bluebubbles.py index a95dd47dc08..60fe57031d9 100644 --- a/gateway/platforms/bluebubbles.py +++ b/gateway/platforms/bluebubbles.py @@ -40,6 +40,10 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- DEFAULT_WEBHOOK_HOST = "127.0.0.1" +# BlueBubbles webhook events are small JSON/form payloads; attachments come +# through the REST API, not the webhook. 1 MiB is generous headroom while +# keeping oversized/chunked bodies from being buffered unbounded. +_WEBHOOK_MAX_BODY_BYTES = 1_048_576 DEFAULT_WEBHOOK_PORT = 8645 DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook" MAX_TEXT_LENGTH = 4000 @@ -264,7 +268,11 @@ class BlueBubblesAdapter(BasePlatformAdapter): self.client = None return False - app = web.Application() + # Explicit body cap: BlueBubbles webhook events are small JSON (or + # form-encoded) payloads. client_max_size makes aiohttp enforce the + # cap on every read path — including chunked requests that carry no + # Content-Length (same pattern as webhook.py / raft, #58536/#58902). + app = web.Application(client_max_size=_WEBHOOK_MAX_BODY_BYTES) app.router.add_get("/health", lambda _: web.Response(text="ok")) app.router.add_post(self.webhook_path, self._handle_webhook) # The webhook auth value is carried in the query string because the diff --git a/hermes_cli/proxy/server.py b/hermes_cli/proxy/server.py index 17e8615fcbd..66749ba664a 100644 --- a/hermes_cli/proxy/server.py +++ b/hermes_cli/proxy/server.py @@ -50,6 +50,10 @@ _HOP_BY_HOP_HEADERS = frozenset( DEFAULT_PORT = 8645 DEFAULT_HOST = "127.0.0.1" +# Body cap for forwarded requests. Chat-completion payloads with long agent +# conversations can be large; mirror api_server's MAX_REQUEST_BYTES (10 MB). +# client_max_size bounds every read path, including chunked bodies. +MAX_REQUEST_BYTES = 10_000_000 def _json_error(status: int, message: str, code: str = "proxy_error") -> "web.Response": @@ -89,7 +93,7 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application": "pip install 'hermes-agent[messaging]' or `pip install aiohttp`." ) - app = web.Application() + app = web.Application(client_max_size=MAX_REQUEST_BYTES) # AppKey ensures forward-compat with future aiohttp versions that strip # bare-string keys. _adapter_key = web.AppKey("adapter", UpstreamAdapter) diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py index bc204b98b38..43230098379 100644 --- a/plugins/platforms/teams/adapter.py +++ b/plugins/platforms/teams/adapter.py @@ -102,6 +102,9 @@ from gateway.platforms.base import ( logger = logging.getLogger(__name__) _DEFAULT_PORT = 3978 +# Bot Framework activities are JSON payloads well under 1 MiB; an explicit +# aiohttp client_max_size keeps oversized/chunked request bodies bounded. +_MAX_BODY_BYTES = 1_048_576 _WEBHOOK_PATH = "/api/messages" @@ -738,8 +741,12 @@ class TeamsAdapter(BasePlatformAdapter): return False try: - # Set up aiohttp app first — the bridge adapter wires SDK routes into it - aiohttp_app = web.Application() + # Set up aiohttp app first — the bridge adapter wires SDK routes into it. + # client_max_size: Bot Framework activities are JSON (caps out well + # under 1 MiB); an explicit cap keeps oversized/chunked bodies from + # being buffered unbounded on a 0.0.0.0 bind (same pattern as + # webhook.py / raft, #58536/#58902). + aiohttp_app = web.Application(client_max_size=_MAX_BODY_BYTES) aiohttp_app.router.add_get("/health", lambda _: web.Response(text="ok")) self._app = App( diff --git a/tests/gateway/test_aiohttp_body_caps.py b/tests/gateway/test_aiohttp_body_caps.py new file mode 100644 index 00000000000..74beba50428 --- /dev/null +++ b/tests/gateway/test_aiohttp_body_caps.py @@ -0,0 +1,36 @@ +"""Regression tests: aiohttp servers must set an explicit ``client_max_size``. + +Without it, aiohttp falls back to its implicit 1 MiB default and — worse — +handlers that only check ``Content-Length`` can be bypassed entirely by +chunked transfer-encoding requests (#58536 webhook, #58902 raft lineage). +These tests pin the wiring for the three servers fixed in this follow-up: +bluebubbles, teams, and the ``hermes proxy`` server. +""" + +import inspect + + +def test_bluebubbles_app_sets_client_max_size(): + import gateway.platforms.bluebubbles as bb + + assert bb._WEBHOOK_MAX_BODY_BYTES > 0 + src = inspect.getsource(bb.BlueBubblesAdapter.connect) + assert "client_max_size=_WEBHOOK_MAX_BODY_BYTES" in src + + +def test_teams_app_sets_client_max_size(): + import plugins.platforms.teams.adapter as teams + + assert teams._MAX_BODY_BYTES > 0 + src = inspect.getsource(teams.TeamsAdapter.connect) + assert "client_max_size=_MAX_BODY_BYTES" in src + + +def test_proxy_app_sets_client_max_size(): + import hermes_cli.proxy.server as proxy_server + + # Mirrors api_server's MAX_REQUEST_BYTES: chat payloads can be large, + # but the cap must exist so chunked bodies stay bounded. + assert proxy_server.MAX_REQUEST_BYTES >= 1_048_576 + src = inspect.getsource(proxy_server.create_app) + assert "client_max_size=MAX_REQUEST_BYTES" in src