Skip to content
Closed
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
41 changes: 34 additions & 7 deletions gateway/platforms/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@
_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
Expand Down Expand Up @@ -259,6 +263,31 @@ async def _handle_health(self, request: "web.Request") -> "web.Response":
"""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
Expand Down Expand Up @@ -306,16 +335,14 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response":
)

# ── 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)
Expand Down
41 changes: 41 additions & 0 deletions tests/gateway/test_webhook_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,29 @@ async def _read():
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(
Expand Down Expand Up @@ -516,6 +539,24 @@ async def test_oversized_payload_rejected(self):
)
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
Expand Down
Loading