From 51d36e86cdec2ef968edd16f55a75b05616d4be0 Mon Sep 17 00:00:00 2001 From: lester076 <2665063734@qq.com> Date: Mon, 2 Nov 2026 18:32:59 +0800 Subject: [PATCH] fix(a2a): validate inline pushNotificationConfig on message/send and message/stream tasks/pushNotificationConfig/set runs _validate_push_notification_url (HTTPS + SSRF guard) before forwarding a webhook, but the same config registered inline via message/send or message/stream (A2A 1.0 configuration. pushNotificationConfig, spec 3.2.1/6.6) was passed through _build_message_ send_params unchecked, so a key holder could make the agent POST task data to an http:// or internal address the set method refuses. Run the same validation on both inline paths: the direct-validate build checks the built MessageSendConfiguration.push_notification_config, and the proto-fallback path (whose 1.0->0.3 conversion drops the config) validates the raw wire configuration instead. A refused URL now gets the same 400 shape as set, and nothing reaches the agent. Fixes #49622 --- .../proxy/agent_endpoints/a2a_endpoints.py | 33 +++++- .../agent_endpoints/test_a2a_endpoints.py | 100 ++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/agent_endpoints/a2a_endpoints.py b/litellm/proxy/agent_endpoints/a2a_endpoints.py index 3dfaa7f213e..f3acce9b640 100644 --- a/litellm/proxy/agent_endpoints/a2a_endpoints.py +++ b/litellm/proxy/agent_endpoints/a2a_endpoints.py @@ -149,7 +149,7 @@ def _build_message_send_params(params: dict[str, Any]) -> "MessageSendParams": from a2a.compat.v0_3.types import MessageSendParams try: - return MessageSendParams(**params) + built = MessageSendParams(**params) except ValidationError: from a2a.compat.v0_3.conversions import pb2_v10, to_compat_send_message_request from google.protobuf.json_format import ParseDict, ParseError @@ -159,7 +159,36 @@ def _build_message_send_params(params: dict[str, Any]) -> "MessageSendParams": ParseDict(params, pb, ignore_unknown_fields=True) except ParseError as e: raise ValueError(f"Invalid message/send params: {e}") from e - return to_compat_send_message_request(pb, "").params + built = to_compat_send_message_request(pb, "").params + # The 1.0->0.3 conversion drops pushNotificationConfig, so validate the + # raw wire config on this path. + _validate_inline_push_notification_url(params) + return built + # A push config registered inline via message/send or message/stream must + # clear the same SSRF guard the tasks/pushNotificationConfig/set method + # applies, otherwise message/send is a way around it (#49622). + push_config = built.configuration.push_notification_config if built.configuration else None + callback_url = push_config.url if push_config is not None else None + if callback_url: + _validate_push_notification_url(str(callback_url)) + return built + + +def _validate_inline_push_notification_url(params: Mapping[str, Any]) -> None: + """Reject an unsafe inline pushNotificationConfig carried in wire params. + + Covers the A2A 1.0 shape (``configuration.pushNotificationConfig.url``) on the + proto-fallback path, where the built params no longer carry the config. + """ + configuration = params.get("configuration") + if not isinstance(configuration, Mapping): + return + push_config = configuration.get("pushNotificationConfig") or configuration.get("push_notification_config") + if not isinstance(push_config, Mapping): + return + callback_url = push_config.get("url") + if isinstance(callback_url, str) and callback_url: + _validate_push_notification_url(callback_url) def _served_version(agent: "AgentResponse", request: Request, original_method: str | None = None) -> A2AVersion: diff --git a/tests/unit/proxy/agent_endpoints/test_a2a_endpoints.py b/tests/unit/proxy/agent_endpoints/test_a2a_endpoints.py index 88da07c612b..d2f65caa73b 100644 --- a/tests/unit/proxy/agent_endpoints/test_a2a_endpoints.py +++ b/tests/unit/proxy/agent_endpoints/test_a2a_endpoints.py @@ -3196,3 +3196,103 @@ def test_a2a_http_refusals_answer_as_jsonrpc_errors_with_a_null_id_for_unparseab assert body["error"]["code"] == -32000 assert body["error"]["message"] == "Agent 'agent-1' is not allowed" assert body["error"]["data"]["code"] == "403" + + +def test_message_send_params_rejects_inline_http_push_url(): + """Regression (#49622): message/send's inline pushNotificationConfig must clear the same SSRF guard as tasks/pushNotificationConfig/set.""" + from fastapi import HTTPException + + from litellm.proxy.agent_endpoints.a2a_endpoints import _build_message_send_params + + params = { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + }, + "configuration": { + "pushNotificationConfig": { + "url": "http://webhook.example.com/hook", + "token": "tok", + } + }, + } + + with pytest.raises(HTTPException) as exc_info: + _build_message_send_params(params) + + assert exc_info.value.status_code == 400 + assert "HTTPS" in exc_info.value.detail + + +def test_message_send_params_rejects_inline_private_address_push_url(): + """Regression (#49622): a link-local inline push URL is refused before anything reaches the agent.""" + import socket as socket_module + + from fastapi import HTTPException + + from litellm.proxy.agent_endpoints.a2a_endpoints import _build_message_send_params + + params = { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + }, + "configuration": { + "pushNotificationConfig": { + "url": "https://169.254.169.254/latest/meta-data/", + } + }, + } + + with pytest.raises(HTTPException) as exc_info: + _build_message_send_params(params) + + assert exc_info.value.status_code == 400 + + +def test_message_send_params_accepts_https_public_push_url(): + """A compliant inline push config passes through unchanged.""" + from litellm.proxy.agent_endpoints.a2a_endpoints import _build_message_send_params + + params = { + "message": { + "messageId": "msg-1", + "role": "user", + "parts": [{"text": "hello"}], + }, + "configuration": { + "pushNotificationConfig": { + "url": "https://webhook.example.com/hook", + "token": "tok", + } + }, + } + + # The public-address SSRF check itself is covered by the dedicated + # _validate_push_notification_url tests; keep this test offline by + # letting the (already scheme-validated) https URL through. + with patch("litellm.proxy.agent_endpoints.a2a_endpoints.validate_url", return_value=None): + result = _build_message_send_params(params) + assert result.message.role.value == "user" + assert result.configuration is not None + assert result.configuration.push_notification_config is not None + assert result.configuration.push_notification_config.url == "https://webhook.example.com/hook" + + +def test_message_send_params_without_push_config_is_untouched(): + """No inline push config: nothing extra is validated, plain sends behave as before.""" + from litellm.proxy.agent_endpoints.a2a_endpoints import _build_message_send_params + + result = _build_message_send_params( + { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + }, + "configuration": {"blocking": True}, + } + ) + assert result.message.role.value == "user"