From 324b28dddd0044d31f2240b281ab57520b78730e Mon Sep 17 00:00:00 2001 From: lester076 <2665063734@qq.com> Date: Mon, 2 Nov 2026 23:57:05 +0800 Subject: [PATCH] fix(a2a): close the taskPushNotificationConfig inline bypass The A2A 1.0 taskPushNotificationConfig spelling (camelCase or snake_case) survives the 1.0->0.3 conversion into built.configuration.push_notification_ config, but the fallback path returned before the built-object check ran, so an authenticated caller could still register an http:// or loopback webhook via message/send or message/stream. Drop the early return so the built-object validation runs on both paths, and extend the raw wire check with the taskPushNotificationConfig / task_push_notification_config spellings for the configs the conversion does drop. Reviewed-by: veria-ai[bot] (flagged at a2a_endpoints.py:166) --- .../proxy/agent_endpoints/a2a_endpoints.py | 27 +++++++--- .../agent_endpoints/test_a2a_endpoints.py | 53 +++++++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/agent_endpoints/a2a_endpoints.py b/litellm/proxy/agent_endpoints/a2a_endpoints.py index f3acce9b640..9ccca4298c1 100644 --- a/litellm/proxy/agent_endpoints/a2a_endpoints.py +++ b/litellm/proxy/agent_endpoints/a2a_endpoints.py @@ -161,12 +161,13 @@ def _build_message_send_params(params: dict[str, Any]) -> "MessageSendParams": raise ValueError(f"Invalid message/send params: {e}") from e 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. + # raw wire config on this path too. _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). + # applies, otherwise message/send is a way around it (#49622). Runs on both + # paths: the 1.0 taskPushNotificationConfig spelling survives the + # conversion into built.configuration.push_notification_config. 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: @@ -177,14 +178,26 @@ def _build_message_send_params(params: dict[str, Any]) -> "MessageSendParams": 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. + Covers the A2A 1.0 shapes (``configuration.pushNotificationConfig.url`` and + ``configuration.taskPushNotificationConfig.url``, camelCase or snake_case) + on the proto-fallback path, where a dropped config no longer exists in the + built params. """ 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): + push_config: Any = None + for key in ( + "pushNotificationConfig", + "push_notification_config", + "taskPushNotificationConfig", + "task_push_notification_config", + ): + value = configuration.get(key) + if isinstance(value, Mapping): + push_config = value + break + if push_config is None: return callback_url = push_config.get("url") if isinstance(callback_url, str) and callback_url: diff --git a/tests/unit/proxy/agent_endpoints/test_a2a_endpoints.py b/tests/unit/proxy/agent_endpoints/test_a2a_endpoints.py index d2f65caa73b..e8b4e3139ba 100644 --- a/tests/unit/proxy/agent_endpoints/test_a2a_endpoints.py +++ b/tests/unit/proxy/agent_endpoints/test_a2a_endpoints.py @@ -3296,3 +3296,56 @@ def test_message_send_params_without_push_config_is_untouched(): } ) assert result.message.role.value == "user" + + +def test_message_send_params_rejects_a2a_10_task_push_url_loopback(): + """Regression (#49637 review): A2A 1.0 taskPushNotificationConfig survives the + 1.0->0.3 conversion into built.configuration.push_notification_config, so the + built-object check must run on the fallback path too — a loopback webhook on + message/send is refused before reaching the agent.""" + 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": { + "taskPushNotificationConfig": { + "url": "http://127.0.0.1:8080/hook", + } + }, + } + + with pytest.raises(HTTPException) as exc_info: + _build_message_send_params(params) + + assert exc_info.value.status_code == 400 + + +def test_message_send_params_rejects_snake_case_task_push_url(): + """The snake_case task_push_notification_config wire spelling is covered too.""" + 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": { + "task_push_notification_config": { + "url": "http://10.0.0.9/hook", + } + }, + } + + with pytest.raises(HTTPException) as exc_info: + _build_message_send_params(params) + + assert exc_info.value.status_code == 400