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
This commit is contained in:
lester076 2026-11-02 18:32:59 +08:00
parent 8ce5e5c313
commit 51d36e86cd
2 changed files with 131 additions and 2 deletions

View file

@ -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:

View file

@ -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"