mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(webhook): cap chunked request bodies
This commit is contained in:
parent
f81c0394d0
commit
6cbda6c102
2 changed files with 75 additions and 7 deletions
|
|
@ -60,6 +60,10 @@ _INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
|
|||
_DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json"
|
||||
|
||||
|
||||
class _PayloadTooLarge(ValueError):
|
||||
"""Raised when an inbound webhook body exceeds the configured limit."""
|
||||
|
||||
|
||||
def check_webhook_requirements() -> bool:
|
||||
"""Check if webhook adapter dependencies are available."""
|
||||
return AIOHTTP_AVAILABLE
|
||||
|
|
@ -259,6 +263,31 @@ class WebhookAdapter(BasePlatformAdapter):
|
|||
"""GET /health — simple health check."""
|
||||
return web.json_response({"status": "ok", "platform": "webhook"})
|
||||
|
||||
async def _read_body_with_limit(self, request: "web.Request") -> bytes:
|
||||
"""Read a webhook body while enforcing max_body_bytes for chunked uploads."""
|
||||
content_length = request.content_length
|
||||
if content_length is not None and content_length > self._max_body_bytes:
|
||||
raise _PayloadTooLarge
|
||||
|
||||
if content_length is None:
|
||||
content = getattr(request, "content", None)
|
||||
iter_chunked = getattr(content, "iter_chunked", None)
|
||||
if iter_chunked is not None:
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
chunk_size = min(64 * 1024, self._max_body_bytes + 1)
|
||||
async for chunk in iter_chunked(chunk_size):
|
||||
total += len(chunk)
|
||||
if total > self._max_body_bytes:
|
||||
raise _PayloadTooLarge
|
||||
chunks.append(bytes(chunk))
|
||||
return b"".join(chunks)
|
||||
|
||||
raw_body = await request.read()
|
||||
if len(raw_body) > self._max_body_bytes:
|
||||
raise _PayloadTooLarge
|
||||
return raw_body
|
||||
|
||||
def _reload_dynamic_routes(self) -> None:
|
||||
"""Reload agent-created subscriptions from disk if the file changed."""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
|
@ -306,16 +335,14 @@ class WebhookAdapter(BasePlatformAdapter):
|
|||
)
|
||||
|
||||
# ── Auth-before-body ─────────────────────────────────────
|
||||
# Check Content-Length before reading the full payload.
|
||||
content_length = request.content_length or 0
|
||||
if content_length > self._max_body_bytes:
|
||||
# Enforce max size before reading known-length bodies and while
|
||||
# streaming chunked/no-length bodies.
|
||||
try:
|
||||
raw_body = await self._read_body_with_limit(request)
|
||||
except _PayloadTooLarge:
|
||||
return web.json_response(
|
||||
{"error": "Payload too large"}, status=413
|
||||
)
|
||||
|
||||
# Read body (must be done before any validation)
|
||||
try:
|
||||
raw_body = await request.read()
|
||||
except Exception as e:
|
||||
logger.error("[webhook] Failed to read body: %s", e)
|
||||
return web.json_response({"error": "Bad request"}, status=400)
|
||||
|
|
|
|||
|
|
@ -88,6 +88,29 @@ def _mock_request(headers=None, body=b"", content_length=None, match_info=None):
|
|||
return req
|
||||
|
||||
|
||||
class _ChunkedBody:
|
||||
def __init__(self, chunks):
|
||||
self._chunks = chunks
|
||||
|
||||
async def iter_chunked(self, _chunk_size):
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
class _ChunkedRequest:
|
||||
def __init__(self, *, chunks, headers=None, match_info=None):
|
||||
self.headers = headers or {}
|
||||
self.content_length = None
|
||||
self.match_info = match_info or {}
|
||||
self.method = "POST"
|
||||
self.content = _ChunkedBody(chunks)
|
||||
self.read_called = False
|
||||
|
||||
async def read(self):
|
||||
self.read_called = True
|
||||
return b"".join(self.content._chunks)
|
||||
|
||||
|
||||
def _github_signature(body: bytes, secret: str) -> str:
|
||||
"""Compute X-Hub-Signature-256 for *body* using *secret*."""
|
||||
return "sha256=" + hmac.new(
|
||||
|
|
@ -516,6 +539,24 @@ class TestBodySize:
|
|||
)
|
||||
assert resp.status == 413
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chunked_payload_without_content_length_rejected(self):
|
||||
"""Chunked/no-length bodies are capped while streaming."""
|
||||
routes = {"big": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}}
|
||||
adapter = _make_adapter(routes=routes, max_body_bytes=100)
|
||||
adapter.handle_message = AsyncMock()
|
||||
request = _ChunkedRequest(
|
||||
chunks=[b'{"data":"', b"x" * 128, b'"}'],
|
||||
headers={"Content-Type": "application/json"},
|
||||
match_info={"route_name": "big"},
|
||||
)
|
||||
|
||||
resp = await adapter._handle_webhook(request)
|
||||
|
||||
assert resp.status == 413
|
||||
assert request.read_called is False
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# INSECURE_NO_AUTH
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue