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:
Alix-007 2026-06-29 12:22:01 +08:00 committed by Teknium
parent c5a8df3af2
commit 940b69b1a8
2 changed files with 40 additions and 1 deletions

View file

@ -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:

View file

@ -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