mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
fix(sms): bound Twilio webhook body reads to prevent OOM
_handle_webhook() called request.read() with no size guard. Since the endpoint is publicly reachable, an attacker can send an arbitrarily large POST body to exhaust gateway memory. Add _TWILIO_WEBHOOK_MAX_BODY_BYTES (64 KiB — well above any real Twilio payload) and gate on both Content-Length and actual read size, returning HTTP 413 with an empty TwiML Response on oversized requests. Mirrors the guard already present in the Raft adapter.
This commit is contained in:
parent
c5a8df3af2
commit
940b69b1a8
2 changed files with 40 additions and 1 deletions
|
|
@ -42,6 +42,7 @@ TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts"
|
|||
MAX_SMS_LENGTH = 1600 # ~10 SMS segments
|
||||
DEFAULT_WEBHOOK_PORT = 8080
|
||||
DEFAULT_WEBHOOK_HOST = "127.0.0.1"
|
||||
_TWILIO_WEBHOOK_MAX_BODY_BYTES = 65_536 # 64 KiB — Twilio payloads are small
|
||||
|
||||
|
||||
def check_sms_requirements() -> bool:
|
||||
|
|
@ -293,7 +294,20 @@ class SmsAdapter(BasePlatformAdapter):
|
|||
from aiohttp import web
|
||||
|
||||
try:
|
||||
content_length = request.content_length
|
||||
if content_length is not None and content_length > _TWILIO_WEBHOOK_MAX_BODY_BYTES:
|
||||
return web.Response(
|
||||
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||
content_type="application/xml",
|
||||
status=413,
|
||||
)
|
||||
raw = await request.read()
|
||||
if len(raw) > _TWILIO_WEBHOOK_MAX_BODY_BYTES:
|
||||
return web.Response(
|
||||
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||
content_type="application/xml",
|
||||
status=413,
|
||||
)
|
||||
# Twilio sends form-encoded data, not JSON
|
||||
form = urllib.parse.parse_qs(raw.decode("utf-8"), keep_blank_values=True)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -459,10 +459,11 @@ class TestWebhookSignatureEnforcement:
|
|||
adapter._message_handler = AsyncMock()
|
||||
return adapter
|
||||
|
||||
def _mock_request(self, body, headers=None):
|
||||
def _mock_request(self, body, headers=None, content_length=None):
|
||||
request = MagicMock()
|
||||
request.read = AsyncMock(return_value=body)
|
||||
request.headers = headers or {}
|
||||
request.content_length = content_length
|
||||
return request
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -536,3 +537,27 @@ class TestWebhookSignatureEnforcement:
|
|||
request = self._mock_request(body, headers={"X-Twilio-Signature": sig})
|
||||
resp = await adapter._handle_webhook(request)
|
||||
assert resp.status == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_rejects_oversized_body_via_content_length(self):
|
||||
"""POST with Content-Length exceeding 64 KiB returns 413 before reading."""
|
||||
adapter = self._make_adapter(webhook_url="")
|
||||
body = b"From=%2B15551234567&To=%2B15550001111&Body=hello&MessageSid=SM123"
|
||||
request = self._mock_request(body, content_length=65_537)
|
||||
resp = await adapter._handle_webhook(request)
|
||||
assert resp.status == 413
|
||||
# request.read must NOT have been called — we bailed on Content-Length
|
||||
request.read.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_rejects_oversized_body_via_read_length(self):
|
||||
"""POST whose actual read size exceeds 64 KiB returns 413.
|
||||
|
||||
Covers the case where Content-Length is absent (chunked transfer) but
|
||||
the body still exceeds the cap.
|
||||
"""
|
||||
adapter = self._make_adapter(webhook_url="")
|
||||
oversized = b"x" * 65_537
|
||||
request = self._mock_request(oversized, content_length=None)
|
||||
resp = await adapter._handle_webhook(request)
|
||||
assert resp.status == 413
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue