From 940b69b1a8610b3ebfe21386ef8a382fd1204e94 Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Mon, 29 Jun 2026 12:22:01 +0800
Subject: [PATCH] fix(sms): bound Twilio webhook body reads to prevent OOM
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
_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.
---
plugins/platforms/sms/adapter.py | 14 ++++++++++++++
tests/gateway/test_sms.py | 27 ++++++++++++++++++++++++++-
2 files changed, 40 insertions(+), 1 deletion(-)
diff --git a/plugins/platforms/sms/adapter.py b/plugins/platforms/sms/adapter.py
index e40ec0ac39a..1646b1b5956 100644
--- a/plugins/platforms/sms/adapter.py
+++ b/plugins/platforms/sms/adapter.py
@@ -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='',
+ content_type="application/xml",
+ status=413,
+ )
raw = await request.read()
+ if len(raw) > _TWILIO_WEBHOOK_MAX_BODY_BYTES:
+ return web.Response(
+ text='',
+ 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:
diff --git a/tests/gateway/test_sms.py b/tests/gateway/test_sms.py
index 85a9501f06a..bfb70289b75 100644
--- a/tests/gateway/test_sms.py
+++ b/tests/gateway/test_sms.py
@@ -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