diff --git a/gateway/platforms/msgraph_webhook.py b/gateway/platforms/msgraph_webhook.py index 88781d1f547..04058a41a01 100644 --- a/gateway/platforms/msgraph_webhook.py +++ b/gateway/platforms/msgraph_webhook.py @@ -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") diff --git a/tests/gateway/test_msgraph_webhook.py b/tests/gateway/test_msgraph_webhook.py index caa141c6a44..a731e9287da 100644 --- a/tests/gateway/test_msgraph_webhook.py +++ b/tests/gateway/test_msgraph_webhook.py @@ -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."""