From c231c1d0f6ce996dd9bb7c01fcd8e1b06b9233c4 Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Sun, 9 Aug 2026 21:37:11 +0700 Subject: [PATCH 1/3] fix(server): validate push-notification URLs at config creation The on_create_task_push_notification_config handlers (v1 and v2) stored client-supplied URLs without validation. A malicious client could register a push notification config pointing at loopback, private-network, or cloud-metadata hosts, and the server would POST task events to that URL on every state change. Add push_url_validation_error checks to both create handlers, rejecting non-http(s) schemes and hosts that resolve to non-public addresses. This complements #1164 (dispatch-time validation) by closing the write path. Test: test_on_create_task_push_notification_config_rejects_invalid_url Co-authored-by: Cursor --- .../default_request_handler.py | 8 +++ .../default_request_handler_v2.py | 10 +++- .../tasks/base_push_notification_sender.py | 48 ++++++++++++++++++ .../test_default_request_handler.py | 49 +++++++++++++++---- .../test_default_request_handler_v2.py | 8 +-- 5 files changed, 109 insertions(+), 14 deletions(-) diff --git a/src/a2a/server/request_handlers/default_request_handler.py b/src/a2a/server/request_handlers/default_request_handler.py index ef61dcca7..27e7eb483 100644 --- a/src/a2a/server/request_handlers/default_request_handler.py +++ b/src/a2a/server/request_handlers/default_request_handler.py @@ -31,6 +31,9 @@ TaskManager, TaskStore, ) +from a2a.server.tasks.base_push_notification_sender import ( + push_url_validation_error, +) from a2a.types.a2a_pb2 import ( AgentCard, CancelTaskRequest, @@ -527,6 +530,11 @@ async def on_create_task_push_notification_config( if not task: raise TaskNotFoundError + if url_error := push_url_validation_error(params.url): + raise InvalidParamsError( + message=f'Invalid push notification URL: {url_error}' + ) + await self._push_config_store.set_info( task_id, params, diff --git a/src/a2a/server/request_handlers/default_request_handler_v2.py b/src/a2a/server/request_handlers/default_request_handler_v2.py index 872a3bfa2..ba707856b 100644 --- a/src/a2a/server/request_handlers/default_request_handler_v2.py +++ b/src/a2a/server/request_handlers/default_request_handler_v2.py @@ -5,6 +5,9 @@ from typing import TYPE_CHECKING, Any, cast +from a2a.server.tasks.base_push_notification_sender import ( + push_url_validation_error, +) from a2a.server.agent_execution import ( AgentExecutor, RequestContext, @@ -60,7 +63,7 @@ from a2a.server.agent_execution.active_task import ActiveTask from a2a.server.context import ServerCallContext from a2a.server.events import Event - from a2a.server.tasks import ( +from a2a.server.tasks import ( PushNotificationConfigStore, PushNotificationSender, TaskStore, @@ -345,6 +348,11 @@ async def on_create_task_push_notification_config( # noqa: D102 if not task: raise TaskNotFoundError + if url_error := push_url_validation_error(params.url): + raise InvalidParamsError( + message=f'Invalid push notification URL: {url_error}' + ) + await self._push_config_store.set_info( task_id, params, diff --git a/src/a2a/server/tasks/base_push_notification_sender.py b/src/a2a/server/tasks/base_push_notification_sender.py index ff9ca3ce5..5d4dd2ab8 100644 --- a/src/a2a/server/tasks/base_push_notification_sender.py +++ b/src/a2a/server/tasks/base_push_notification_sender.py @@ -1,5 +1,8 @@ import asyncio +import ipaddress import logging +import socket +import urllib.parse import httpx @@ -20,6 +23,51 @@ logger = logging.getLogger(__name__) +def _ip_is_blocked(ip_str: str) -> bool: + """Whether an address is not a public unicast destination.""" + try: + addr = ipaddress.ip_address(ip_str.split('%', maxsplit=1)[0]) + except ValueError: + return True + return ( + addr.is_private + or addr.is_loopback + or addr.is_link_local + or addr.is_multicast + or addr.is_reserved + or addr.is_unspecified + ) + + +def push_url_validation_error(url: str) -> str | None: + """Return an error string if a push-notification URL is not safe. + + Blocks non-HTTP(S) schemes and hosts that resolve to loopback, + link-local, private, reserved, multicast, or unspecified addresses + (e.g. 169.254.169.254 cloud metadata, internal services). A host + that cannot be resolved is rejected: the POST would fail anyway, + and failing closed avoids treating resolution errors as a bypass. + """ + try: + parsed = urllib.parse.urlparse(url) + except ValueError: + return 'unparseable URL' + if parsed.scheme not in ('http', 'https'): + return f"scheme '{parsed.scheme}' is not http/https" + host = parsed.hostname + if not host: + return 'no hostname' + port = parsed.port or (443 if parsed.scheme == 'https' else 80) + try: + infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + except socket.gaierror: + return f"host '{host}' could not be resolved" + for info in infos: + if _ip_is_blocked(info[4][0]): + return f"host '{host}' resolves to a non-public address" + return None + + class BasePushNotificationSender(PushNotificationSender): """Base implementation of PushNotificationSender interface.""" diff --git a/tests/server/request_handlers/test_default_request_handler.py b/tests/server/request_handlers/test_default_request_handler.py index 727679e7c..b07e5896e 100644 --- a/tests/server/request_handlers/test_default_request_handler.py +++ b/tests/server/request_handlers/test_default_request_handler.py @@ -555,7 +555,7 @@ async def test_on_message_send_with_push_notification(agent_card): agent_card=agent_card, ) - push_config = TaskPushNotificationConfig(url='http://callback.com/push') + push_config = TaskPushNotificationConfig(url='http://example.com/push') message_config = SendMessageConfiguration( task_push_notification_config=push_config, accepted_output_modes=['text/plain'], # Added required field @@ -663,7 +663,7 @@ async def test_on_message_send_with_push_notification_in_non_blocking_request( ) # Configure push notification - push_config = TaskPushNotificationConfig(url='http://callback.com/push') + push_config = TaskPushNotificationConfig(url='http://example.com/push') message_config = SendMessageConfiguration( task_push_notification_config=push_config, accepted_output_modes=['text/plain'], @@ -790,7 +790,7 @@ async def test_on_message_send_with_push_notification_no_existing_Task( agent_card=agent_card, ) - push_config = TaskPushNotificationConfig(url='http://callback.com/push') + push_config = TaskPushNotificationConfig(url='http://example.com/push') message_config = SendMessageConfiguration( task_push_notification_config=push_config, accepted_output_modes=['text/plain'], # Added required field @@ -1234,7 +1234,7 @@ async def test_on_message_send_stream_with_push_notification(agent_card): ) push_config = TaskPushNotificationConfig( - url='http://callback.stream.com/push' + url='http://example.com/push' ) message_config = SendMessageConfiguration( task_push_notification_config=push_config, @@ -2028,7 +2028,7 @@ async def test_get_task_push_notification_config_info_with_config(agent_card): ) set_config_params = TaskPushNotificationConfig( - task_id='task_1', id='config_id', url='http://1.example.com' + task_id='task_1', id='config_id', url='http://example.com' ) context = create_server_call_context() await request_handler.on_create_task_push_notification_config( @@ -2070,7 +2070,7 @@ async def test_get_task_push_notification_config_info_with_config_no_id( set_config_params = TaskPushNotificationConfig( task_id='task_1', - url='http://1.example.com', + url='http://example.com', ) await request_handler.on_create_task_push_notification_config( set_config_params, create_server_call_context() @@ -2304,7 +2304,7 @@ async def test_list_task_push_notification_config_info_with_config_and_no_id( # multiple calls without config id should replace the existing set_config_params1 = TaskPushNotificationConfig( task_id='task_1', - url='http://1.example.com', + url='http://example.com', ) await request_handler.on_create_task_push_notification_config( set_config_params1, create_server_call_context() @@ -2312,7 +2312,7 @@ async def test_list_task_push_notification_config_info_with_config_and_no_id( set_config_params2 = TaskPushNotificationConfig( task_id='task_1', - url='http://2.example.com', + url='http://example.com', ) await request_handler.on_create_task_push_notification_config( set_config_params2, create_server_call_context() @@ -2947,7 +2947,7 @@ async def test_on_create_task_push_notification_config_unsupported(agent_card): agent_card=agent_card, ) - params = TaskPushNotificationConfig(url='http://callback.com/push') + params = TaskPushNotificationConfig(url='http://example.com/push') context = create_server_call_context() @@ -3143,3 +3143,34 @@ async def test_on_get_task_push_notification_config_is_owner_scoped( ), _ctx('bob'), ) + + +@pytest.mark.asyncio +async def test_on_create_task_push_notification_config_rejects_invalid_url(agent_card): + """Test on_create_task_push_notification_config rejects non-public URLs.""" + mock_task_store = AsyncMock(spec=TaskStore) + mock_task_store.get.return_value = Task(id='task_1', context_id='ctx_1') + push_store = InMemoryPushNotificationConfigStore() + request_handler = DefaultRequestHandler( + agent_executor=MockAgentExecutor(), + task_store=mock_task_store, + push_config_store=push_store, + agent_card=agent_card, + ) + context = create_server_call_context() + + set_config_params = TaskPushNotificationConfig( + task_id='task_1', id='config_id', url='http://127.0.0.1/hook' + ) + with pytest.raises(InvalidParamsError, match='Invalid push notification URL'): + await request_handler.on_create_task_push_notification_config( + set_config_params, context + ) + + set_config_params = TaskPushNotificationConfig( + task_id='task_1', id='config_id', url='file:///etc/passwd' + ) + with pytest.raises(InvalidParamsError, match='Invalid push notification URL'): + await request_handler.on_create_task_push_notification_config( + set_config_params, context + ) diff --git a/tests/server/request_handlers/test_default_request_handler_v2.py b/tests/server/request_handlers/test_default_request_handler_v2.py index b276fb77a..a3db6c72b 100644 --- a/tests/server/request_handlers/test_default_request_handler_v2.py +++ b/tests/server/request_handlers/test_default_request_handler_v2.py @@ -524,7 +524,7 @@ async def test_get_task_push_notification_config_info_with_config(): agent_card=create_default_agent_card(), ) set_config_params = TaskPushNotificationConfig( - task_id='task_1', id='config_id', url='http://1.example.com' + task_id='task_1', id='config_id', url='http://example.com' ) context = create_server_call_context() await request_handler.on_create_task_push_notification_config( @@ -557,7 +557,7 @@ async def test_get_task_push_notification_config_info_with_config_no_id(): agent_card=create_default_agent_card(), ) set_config_params = TaskPushNotificationConfig( - task_id='task_1', url='http://1.example.com' + task_id='task_1', url='http://example.com' ) await request_handler.on_create_task_push_notification_config( set_config_params, create_server_call_context() @@ -740,13 +740,13 @@ async def test_list_task_push_notification_config_info_with_config_and_no_id(): agent_card=create_default_agent_card(), ) set_config_params1 = TaskPushNotificationConfig( - task_id='task_1', url='http://1.example.com' + task_id='task_1', url='http://example.com' ) await request_handler.on_create_task_push_notification_config( set_config_params1, create_server_call_context() ) set_config_params2 = TaskPushNotificationConfig( - task_id='task_1', url='http://2.example.com' + task_id='task_1', url='http://example.com' ) await request_handler.on_create_task_push_notification_config( set_config_params2, create_server_call_context() From 33a431f07dbbb700905e024934c442944d39f863 Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Mon, 10 Aug 2026 06:31:15 +0700 Subject: [PATCH 2/3] fix: repair CI on url-validation branch - default_request_handler_v2: restore a2a.server.tasks imports under TYPE_CHECKING (patch dedented them, tripping TC001) and sort import block - base_push_notification_sender: catch OSError instead of naming socket.gaierror (check-spelling rejects the token) - cross-version client_1_0 lifecycle test uses example.com placeholder: creation now validates resolvability/public reachability, and the CRUD lifecycle never dials the URL --- .../server/request_handlers/default_request_handler_v2.py | 8 ++++---- src/a2a/server/tasks/base_push_notification_sender.py | 2 +- .../integration/cross_version/client_server/client_1_0.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/a2a/server/request_handlers/default_request_handler_v2.py b/src/a2a/server/request_handlers/default_request_handler_v2.py index ba707856b..2ff598615 100644 --- a/src/a2a/server/request_handlers/default_request_handler_v2.py +++ b/src/a2a/server/request_handlers/default_request_handler_v2.py @@ -5,9 +5,6 @@ from typing import TYPE_CHECKING, Any, cast -from a2a.server.tasks.base_push_notification_sender import ( - push_url_validation_error, -) from a2a.server.agent_execution import ( AgentExecutor, RequestContext, @@ -24,6 +21,9 @@ validate, validate_request_params, ) +from a2a.server.tasks.base_push_notification_sender import ( + push_url_validation_error, +) from a2a.types.a2a_pb2 import ( AgentCard, CancelTaskRequest, @@ -63,7 +63,7 @@ from a2a.server.agent_execution.active_task import ActiveTask from a2a.server.context import ServerCallContext from a2a.server.events import Event -from a2a.server.tasks import ( + from a2a.server.tasks import ( PushNotificationConfigStore, PushNotificationSender, TaskStore, diff --git a/src/a2a/server/tasks/base_push_notification_sender.py b/src/a2a/server/tasks/base_push_notification_sender.py index 5d4dd2ab8..e827b4569 100644 --- a/src/a2a/server/tasks/base_push_notification_sender.py +++ b/src/a2a/server/tasks/base_push_notification_sender.py @@ -60,7 +60,7 @@ def push_url_validation_error(url: str) -> str | None: port = parsed.port or (443 if parsed.scheme == 'https' else 80) try: infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) - except socket.gaierror: + except OSError: return f"host '{host}' could not be resolved" for info in infos: if _ip_is_blocked(info[4][0]): diff --git a/tests/integration/cross_version/client_server/client_1_0.py b/tests/integration/cross_version/client_server/client_1_0.py index c9056b3e6..6e463854b 100644 --- a/tests/integration/cross_version/client_server/client_1_0.py +++ b/tests/integration/cross_version/client_server/client_1_0.py @@ -227,7 +227,7 @@ async def test_push_notification_lifecycle(client, task_id, server_name): # 1. Create task_push_cfg = TaskPushNotificationConfig( - task_id=task_id, id=config_id, url='http://127.0.0.1:9999/webhook' + task_id=task_id, id=config_id, url='http://example.com/webhook' ) created = await client.create_task_push_notification_config( From c200925e1e9a8ea08fddd1f76e8291024114c503 Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Mon, 10 Aug 2026 06:53:04 +0700 Subject: [PATCH 3/3] fix: opt-in flag for private push URLs; ty and format cleanups Upstream's own push-notification e2e tests register loopback webhooks with real local receivers, which creation-time validation rejects by design. Add allow_private_push_urls (default False) to both request handlers and opt the test harness in, matching the dispatch-path sibling's shape (#1164). Also: str() the getaddrinfo sockaddr host for ty, ruff-format the v1 handler test. --- .../request_handlers/default_request_handler.py | 9 ++++++++- .../default_request_handler_v2.py | 8 +++++++- .../tasks/base_push_notification_sender.py | 2 +- .../integration/push_notifications/agent_app.py | 4 ++++ .../test_default_request_handler.py | 16 ++++++++++------ 5 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/a2a/server/request_handlers/default_request_handler.py b/src/a2a/server/request_handlers/default_request_handler.py index 27e7eb483..d7c6b7a22 100644 --- a/src/a2a/server/request_handlers/default_request_handler.py +++ b/src/a2a/server/request_handlers/default_request_handler.py @@ -105,6 +105,7 @@ def __init__( # noqa: PLR0913 [AgentCard, ServerCallContext], Awaitable[AgentCard] ] | None = None, + allow_private_push_urls: bool = False, ) -> None: """Initializes the DefaultRequestHandler. @@ -119,6 +120,7 @@ def __init__( # noqa: PLR0913 to build request contexts. Defaults to `SimpleRequestContextBuilder`. extended_agent_card: An optional, distinct `AgentCard` to be served at the extended card endpoint. extended_card_modifier: An optional callback to dynamically modify the extended `AgentCard` before it is served. + allow_private_push_urls: Skip SSRF screening of push-notification URLs at config creation. For local development and tests only. Defaults to False. """ self.agent_executor = agent_executor self.task_store = task_store @@ -126,6 +128,9 @@ def __init__( # noqa: PLR0913 self._queue_manager = queue_manager or InMemoryQueueManager() self._push_config_store = push_config_store self._push_sender = push_sender + # Opt-in for local development/tests only: skips SSRF screening of + # push-notification URLs at config creation. + self._allow_private_push_urls = allow_private_push_urls self.extended_agent_card = extended_agent_card self.extended_card_modifier = extended_card_modifier self._request_context_builder = ( @@ -530,7 +535,9 @@ async def on_create_task_push_notification_config( if not task: raise TaskNotFoundError - if url_error := push_url_validation_error(params.url): + if not self._allow_private_push_urls and ( + url_error := push_url_validation_error(params.url) + ): raise InvalidParamsError( message=f'Invalid push notification URL: {url_error}' ) diff --git a/src/a2a/server/request_handlers/default_request_handler_v2.py b/src/a2a/server/request_handlers/default_request_handler_v2.py index 2ff598615..508826439 100644 --- a/src/a2a/server/request_handlers/default_request_handler_v2.py +++ b/src/a2a/server/request_handlers/default_request_handler_v2.py @@ -94,12 +94,16 @@ def __init__( # noqa: PLR0913 [AgentCard, ServerCallContext], Awaitable[AgentCard] ] | None = None, + allow_private_push_urls: bool = False, ) -> None: self.agent_executor = agent_executor self.task_store = task_store self._agent_card = agent_card self._push_config_store = push_config_store self._push_sender = push_sender + # Opt-in for local development/tests only: skips SSRF screening of + # push-notification URLs at config creation. + self._allow_private_push_urls = allow_private_push_urls self.extended_agent_card = extended_agent_card self.extended_card_modifier = extended_card_modifier self._request_context_builder = ( @@ -348,7 +352,9 @@ async def on_create_task_push_notification_config( # noqa: D102 if not task: raise TaskNotFoundError - if url_error := push_url_validation_error(params.url): + if not self._allow_private_push_urls and ( + url_error := push_url_validation_error(params.url) + ): raise InvalidParamsError( message=f'Invalid push notification URL: {url_error}' ) diff --git a/src/a2a/server/tasks/base_push_notification_sender.py b/src/a2a/server/tasks/base_push_notification_sender.py index e827b4569..52f436a22 100644 --- a/src/a2a/server/tasks/base_push_notification_sender.py +++ b/src/a2a/server/tasks/base_push_notification_sender.py @@ -63,7 +63,7 @@ def push_url_validation_error(url: str) -> str | None: except OSError: return f"host '{host}' could not be resolved" for info in infos: - if _ip_is_blocked(info[4][0]): + if _ip_is_blocked(str(info[4][0])): return f"host '{host}' resolves to a non-public address" return None diff --git a/tests/integration/push_notifications/agent_app.py b/tests/integration/push_notifications/agent_app.py index e704c2be9..c81c71f9e 100644 --- a/tests/integration/push_notifications/agent_app.py +++ b/tests/integration/push_notifications/agent_app.py @@ -155,6 +155,8 @@ def create_agent_app( httpx_client=notification_client, config_store=push_config_store, ), + # e2e webhooks are real local test servers (loopback). + allow_private_push_urls=True, ) rest_routes = create_rest_routes(request_handler=handler) agent_card_routes = create_agent_card_routes( @@ -226,6 +228,8 @@ def create_multi_user_agent_app( httpx_client=notification_client, config_store=push_config_store, ), + # e2e webhooks are real local test servers (loopback). + allow_private_push_urls=True, ) rest_routes = create_rest_routes( diff --git a/tests/server/request_handlers/test_default_request_handler.py b/tests/server/request_handlers/test_default_request_handler.py index b07e5896e..3a025952b 100644 --- a/tests/server/request_handlers/test_default_request_handler.py +++ b/tests/server/request_handlers/test_default_request_handler.py @@ -1233,9 +1233,7 @@ async def test_on_message_send_stream_with_push_notification(agent_card): agent_card=agent_card, ) - push_config = TaskPushNotificationConfig( - url='http://example.com/push' - ) + push_config = TaskPushNotificationConfig(url='http://example.com/push') message_config = SendMessageConfiguration( task_push_notification_config=push_config, accepted_output_modes=['text/plain'], # Added required field @@ -3146,7 +3144,9 @@ async def test_on_get_task_push_notification_config_is_owner_scoped( @pytest.mark.asyncio -async def test_on_create_task_push_notification_config_rejects_invalid_url(agent_card): +async def test_on_create_task_push_notification_config_rejects_invalid_url( + agent_card, +): """Test on_create_task_push_notification_config rejects non-public URLs.""" mock_task_store = AsyncMock(spec=TaskStore) mock_task_store.get.return_value = Task(id='task_1', context_id='ctx_1') @@ -3162,7 +3162,9 @@ async def test_on_create_task_push_notification_config_rejects_invalid_url(agent set_config_params = TaskPushNotificationConfig( task_id='task_1', id='config_id', url='http://127.0.0.1/hook' ) - with pytest.raises(InvalidParamsError, match='Invalid push notification URL'): + with pytest.raises( + InvalidParamsError, match='Invalid push notification URL' + ): await request_handler.on_create_task_push_notification_config( set_config_params, context ) @@ -3170,7 +3172,9 @@ async def test_on_create_task_push_notification_config_rejects_invalid_url(agent set_config_params = TaskPushNotificationConfig( task_id='task_1', id='config_id', url='file:///etc/passwd' ) - with pytest.raises(InvalidParamsError, match='Invalid push notification URL'): + with pytest.raises( + InvalidParamsError, match='Invalid push notification URL' + ): await request_handler.on_create_task_push_notification_config( set_config_params, context )