mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
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:
parent
8ce5e5c313
commit
51d36e86cd
2 changed files with 131 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue