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)
This commit is contained in:
lester076 2026-11-02 23:57:05 +08:00
parent 51d36e86cd
commit 324b28dddd
2 changed files with 73 additions and 7 deletions

View file

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

View file

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