fix(msgraph): enforce webhook body limits

This commit is contained in:
binhnt92 2026-05-14 07:02:13 +07:00 committed by Teknium
parent 3dd5ce236a
commit 4f4cbff8bd
2 changed files with 94 additions and 3 deletions

View file

@ -34,6 +34,7 @@ DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 8646
DEFAULT_WEBHOOK_PATH = "/msgraph/webhook"
DEFAULT_MAX_SEEN_RECEIPTS = 5000
DEFAULT_MAX_BODY_BYTES = 1_048_576
NotificationScheduler = Callable[[Dict[str, Any], MessageEvent], Awaitable[None] | None]
@ -63,6 +64,9 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
self._max_seen_receipts = max(
1, int(extra.get("max_seen_receipts", DEFAULT_MAX_SEEN_RECEIPTS))
)
self._max_body_bytes = max(
1, int(extra.get("max_body_bytes", DEFAULT_MAX_BODY_BYTES))
)
self._allowed_source_networks: list[ipaddress._BaseNetwork] = (
self._parse_allowed_source_cidrs(extra.get("allowed_source_cidrs"))
)
@ -152,7 +156,7 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
)
return False
app = web.Application()
app = web.Application(client_max_size=self._max_body_bytes)
app.router.add_get(self._health_path, self._handle_health)
app.router.add_get(self._webhook_path, self._handle_validation)
app.router.add_post(self._webhook_path, self._handle_notification)
@ -229,8 +233,24 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
return web.Response(text=validation_token, content_type="text/plain")
try:
body = await request.json()
content_length = request.content_length
except Exception:
content_length = None
if content_length is not None and content_length > self._max_body_bytes:
return web.Response(status=413)
try:
raw_body = await request.read()
except Exception:
return web.Response(status=400)
if len(raw_body) > self._max_body_bytes:
return web.Response(status=413)
try:
body = json.loads(raw_body.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return web.Response(status=400)
if not isinstance(body, dict):
return web.Response(status=400)
notifications = body.get("value")

View file

@ -19,9 +19,19 @@ def _make_adapter(**extra_overrides) -> MSGraphWebhookAdapter:
class _FakeRequest:
def __init__(self, *, query=None, json_payload=None, remote="127.0.0.1"):
def __init__(
self,
*,
query=None,
json_payload=None,
raw_body: bytes | None = None,
content_length: int | None = None,
remote="127.0.0.1",
):
self.query = query or {}
self._json_payload = json_payload
self._raw_body = raw_body
self.content_length = content_length
self.remote = remote
async def json(self):
@ -29,6 +39,11 @@ class _FakeRequest:
raise self._json_payload
return self._json_payload
async def read(self):
if self._raw_body is not None:
return self._raw_body
return json.dumps(self._json_payload or {}).encode("utf-8")
class TestMSGraphWebhookConfig:
def test_gateway_config_accepts_msgraph_webhook_platform(self):
@ -183,6 +198,62 @@ class TestMSGraphNotifications:
assert event.source.chat_type == "webhook"
assert event.message_id == "id:notif-1"
@pytest.mark.anyio
async def test_oversized_notification_rejected_by_content_length(self):
adapter = _make_adapter(max_body_bytes=100)
payload = {
"value": [
{
"id": "notif-oversized",
"subscriptionId": "sub-1",
"changeType": "updated",
"resource": "communications/onlineMeetings/meeting-1",
"clientState": "expected-client-state",
}
]
}
resp = await adapter._handle_notification(
_FakeRequest(json_payload=payload, content_length=101)
)
assert resp.status == 413
@pytest.mark.anyio
async def test_chunked_oversized_notification_rejected_after_read(self):
adapter = _make_adapter(max_body_bytes=100)
payload = {
"value": [
{
"id": "notif-chunked-oversized",
"subscriptionId": "sub-1",
"changeType": "updated",
"resource": "communications/onlineMeetings/meeting-1",
"clientState": "expected-client-state",
}
]
}
resp = await adapter._handle_notification(
_FakeRequest(
json_payload=payload,
raw_body=b"x" * 101,
content_length=None,
)
)
assert resp.status == 413
@pytest.mark.anyio
async def test_non_object_notification_body_rejected(self):
adapter = _make_adapter()
resp = await adapter._handle_notification(
_FakeRequest(json_payload=[], raw_body=b"[]")
)
assert resp.status == 400
@pytest.mark.anyio
async def test_bad_client_state_rejected_as_auth_failure(self):
"""Every-item-bad-clientState batches return 403 so forged POSTs stop retrying."""