From 0e60aa0338f5c3279f4f0dc183cfaf1e3a1b68cb Mon Sep 17 00:00:00 2001 From: Arul Date: Fri, 18 Sep 2026 15:18:35 +0530 Subject: [PATCH] feat(a2a): allow plaintext HTTP for agent cards in service meshes RemoteA2aAgent enforced HTTPS (or loopback hosts) when resolving agent cards and validating RPC targets. In a service mesh or Kubernetes cluster where mTLS is terminated transparently by sidecars (such as Istio or Linkerd), services communicate over plaintext HTTP using internal DNS names, causing RemoteA2aAgent to raise AgentCardResolutionError. Adds an opt-in allow_insecure_http parameter to RemoteA2aAgent.__init__ and A2aRemoteAgentConfig, supported by an ADK_A2A_ALLOW_INSECURE_HTTP=1 environment variable fallback. When enabled, _resolve_agent_card and _validate_card_rpc_targets permit non-loopback http:// URLs while maintaining secure-by-default behavior and enforcing same-origin validation across card sources and RPC targets. Fixes #7139 --- src/google/adk/a2a/agent/__init__.py | 5 + src/google/adk/a2a/agent/_remote_a2a_agent.py | 56 +++++- src/google/adk/a2a/agent/config.py | 10 +- src/google/adk/agents/remote_a2a_agent.py | 2 + .../a2a/agent/test_remote_a2a_agent.py | 190 ++++++++++++++++++ 5 files changed, 253 insertions(+), 10 deletions(-) diff --git a/src/google/adk/a2a/agent/__init__.py b/src/google/adk/a2a/agent/__init__.py index 0e8fc6cc6d..6dfbf59250 100644 --- a/src/google/adk/a2a/agent/__init__.py +++ b/src/google/adk/a2a/agent/__init__.py @@ -20,6 +20,7 @@ "A2aCardRequestConfig", "A2AClientError", "A2aRemoteAgentConfig", + "ADK_A2A_ALLOW_INSECURE_HTTP", "AgentCardResolutionError", "CardRequestInterceptor", "ParametersConfig", @@ -33,6 +34,7 @@ def __getattr__(name: str) -> object: "A2aCardRequestConfig", "A2AClientError", "A2aRemoteAgentConfig", + "ADK_A2A_ALLOW_INSECURE_HTTP", "AgentCardResolutionError", "CardRequestInterceptor", "ParametersConfig", @@ -45,6 +47,7 @@ def __getattr__(name: str) -> object: from ._remote_a2a_agent import RemoteA2aAgent from .config import A2aCardRequestConfig from .config import A2aRemoteAgentConfig + from .config import ADK_A2A_ALLOW_INSECURE_HTTP from .config import CardRequestInterceptor from .config import ParametersConfig from .config import RequestInterceptor @@ -55,6 +58,8 @@ def __getattr__(name: str) -> object: return A2AClientError elif name == "A2aRemoteAgentConfig": return A2aRemoteAgentConfig + elif name == "ADK_A2A_ALLOW_INSECURE_HTTP": + return ADK_A2A_ALLOW_INSECURE_HTTP elif name == "AgentCardResolutionError": return AgentCardResolutionError elif name == "CardRequestInterceptor": diff --git a/src/google/adk/a2a/agent/_remote_a2a_agent.py b/src/google/adk/a2a/agent/_remote_a2a_agent.py index adfcce5463..6f8263b8ab 100644 --- a/src/google/adk/a2a/agent/_remote_a2a_agent.py +++ b/src/google/adk/a2a/agent/_remote_a2a_agent.py @@ -69,6 +69,7 @@ from ...flows.llm_flows.functions import REQUEST_INPUT_FUNCTION_CALL_NAME from ...sessions.session import Session from ...utils.context_utils import Aclosing +from ...utils.env_utils import is_env_enabled from ..converters.event_converter import convert_a2a_message_to_event from ..converters.event_converter import convert_a2a_task_to_event from ..converters.event_converter import convert_event_to_a2a_message @@ -84,6 +85,7 @@ from ..logs.log_utils import build_a2a_response_log from .config import A2aCardRequestConfig from .config import A2aRemoteAgentConfig +from .config import ADK_A2A_ALLOW_INSECURE_HTTP from .config import CardRequestInterceptor from .config import ParametersConfig from .config import RequestInterceptor @@ -95,6 +97,7 @@ __all__ = [ "A2AClientError", + "ADK_A2A_ALLOW_INSECURE_HTTP", "AGENT_CARD_WELL_KNOWN_PATH", "AgentCardResolutionError", "RemoteA2aAgent", @@ -680,6 +683,7 @@ def __init__( auth_scheme: Optional[AuthScheme] = None, auth_credential: Optional[AuthCredential] = None, credential_key: Optional[str] = None, + allow_insecure_http: bool = False, **kwargs: Any, ) -> None: """Initialize RemoteA2aAgent. @@ -714,6 +718,10 @@ def __init__( `auth_scheme` is None. credential_key: Optional key under which the resolved credential is cached. Defaults to a digest of the scheme and the credential. + allow_insecure_http: If True, allow plaintext HTTP for agent card + resolution and RPC targets (e.g. within a service mesh with mTLS). + Defaults to False. Can also be enabled via config or the + ``ADK_A2A_ALLOW_INSECURE_HTTP=1`` environment variable. **kwargs: Additional arguments passed to BaseAgent Raises: @@ -742,7 +750,10 @@ def __init__( self._a2a_request_meta_provider = a2a_request_meta_provider self._full_history_when_stateless_param = full_history_when_stateless self._context_builder = context_builder + self._allow_insecure_http_param = allow_insecure_http self._config = config or A2aRemoteAgentConfig() + if allow_insecure_http: + self._config.allow_insecure_http = True if not use_legacy: if self._config.request_interceptors is None: @@ -818,6 +829,23 @@ def _full_history_when_stateless(self) -> bool: def _full_history_when_stateless(self, value: bool) -> None: self._full_history_when_stateless_param = value + @property + def _allow_insecure_http(self) -> bool: + return ( + self._allow_insecure_http_param + or self._config.allow_insecure_http + or is_env_enabled(ADK_A2A_ALLOW_INSECURE_HTTP) + ) + + @_allow_insecure_http.setter + def _allow_insecure_http(self, value: bool) -> None: + self._allow_insecure_http_param = value + self._config.allow_insecure_http = value + + @property + def allow_insecure_http(self) -> bool: + return self._allow_insecure_http + async def _resolve_auth_credential( self, ctx: InvocationContext ) -> Optional[Event]: @@ -956,11 +984,16 @@ async def _resolve_agent_card( # The card request interceptors attach the credential resolved for this # invocation, so the scheme is checked before the fetch rather than with # the card's RPC targets afterwards -- by then the credential has already - # gone out on the wire. Plain http stays allowed on a loopback host, the - # same carve-out `_validate_card_rpc_targets` applies. + # gone out on the wire. Plain http stays allowed on a loopback host, or + # when allow_insecure_http is True. parsed_source = urlparse(agent_card_source) - if parsed_source.scheme.lower() != "https" and not _is_loopback_host( - parsed_source.hostname + scheme = parsed_source.scheme.lower() + if scheme != "https" and not ( + scheme == "http" + and ( + self._allow_insecure_http + or _is_loopback_host(parsed_source.hostname) + ) ): raise AgentCardResolutionError( "Agent card URL must use https, or http on a loopback host:" @@ -995,9 +1028,9 @@ def _validate_card_rpc_targets(self, agent_card: AgentCard) -> None: Every URL the card offers is checked, not only the one this ADK version would select, because the client factory negotiates the endpoint across - the card's whole interface list. Each must be https and share the origin - the card was fetched from; plain http stays allowed on a loopback host, - the local-development shape the A2A helpers emit. + the card's whole interface list. Each must be https (or http if + allow_insecure_http is True or on a loopback host) and share the origin + the card was fetched from. A card passed in directly or read from a local file did not come off the network here, so its target is left to the caller. @@ -1015,8 +1048,13 @@ def _validate_card_rpc_targets(self, agent_card: AgentCard) -> None: for card_url in _compat.agent_card_rpc_urls(agent_card): parsed_card = urlparse(card_url) - if parsed_card.scheme.lower() != "https" and not _is_loopback_host( - parsed_card.hostname + card_scheme = parsed_card.scheme.lower() + if card_scheme != "https" and not ( + card_scheme == "http" + and ( + self._allow_insecure_http + or _is_loopback_host(parsed_card.hostname) + ) ): raise AgentCardResolutionError( "Agent card RPC URL must use https, or http on a loopback host:" diff --git a/src/google/adk/a2a/agent/config.py b/src/google/adk/a2a/agent/config.py index e7388190f4..4a644e8c6f 100644 --- a/src/google/adk/a2a/agent/config.py +++ b/src/google/adk/a2a/agent/config.py @@ -25,6 +25,7 @@ from a2a.server.events import Event as A2AEvent from a2a.types import Message as A2AMessage from pydantic import BaseModel +from pydantic import Field from typing_extensions import Self from .. import _compat @@ -40,8 +41,11 @@ from ...a2a.converters.to_adk_event import convert_a2a_task_to_event from ...agents.invocation_context import InvocationContext from ...events.event import Event +from ...utils.env_utils import is_env_enabled from .._compat import A2AClientEvent +ADK_A2A_ALLOW_INSECURE_HTTP = "ADK_A2A_ALLOW_INSECURE_HTTP" + class ParametersConfig(BaseModel): """Configuration for the parameters passed to the A2A send_message request.""" @@ -141,6 +145,10 @@ class A2aRemoteAgentConfig(BaseModel): forward_session_id_as_context_id: bool = False """Whether to forward the local session ID as context_id when no context_id is present.""" + allow_insecure_http: bool = Field( + default_factory=lambda: is_env_enabled(ADK_A2A_ALLOW_INSECURE_HTTP) + ) + def __deepcopy__( self, memo: dict[int, Any] | None = None ) -> A2aRemoteAgentConfig: @@ -149,7 +157,7 @@ def __deepcopy__( cls = self.__class__ copied_values: dict[str, Any] = {} for k, v in self.__dict__.items(): - if not k.startswith('_'): + if not k.startswith("_"): if callable(v): copied_values[k] = v else: diff --git a/src/google/adk/agents/remote_a2a_agent.py b/src/google/adk/agents/remote_a2a_agent.py index 1e3d1d0f11..10053b6f45 100644 --- a/src/google/adk/agents/remote_a2a_agent.py +++ b/src/google/adk/agents/remote_a2a_agent.py @@ -17,6 +17,7 @@ from ..a2a.agent._remote_a2a_agent import A2A_METADATA_PREFIX as A2A_METADATA_PREFIX from ..a2a.agent._remote_a2a_agent import A2AClientError as A2AClientError +from ..a2a.agent._remote_a2a_agent import ADK_A2A_ALLOW_INSECURE_HTTP as ADK_A2A_ALLOW_INSECURE_HTTP from ..a2a.agent._remote_a2a_agent import AGENT_CARD_WELL_KNOWN_PATH as AGENT_CARD_WELL_KNOWN_PATH from ..a2a.agent._remote_a2a_agent import AgentCardResolutionError as AgentCardResolutionError from ..a2a.agent._remote_a2a_agent import DEFAULT_TIMEOUT as DEFAULT_TIMEOUT @@ -24,6 +25,7 @@ __all__ = [ "A2AClientError", + "ADK_A2A_ALLOW_INSECURE_HTTP", "AGENT_CARD_WELL_KNOWN_PATH", "AgentCardResolutionError", "RemoteA2aAgent", diff --git a/tests/unittests/a2a/agent/test_remote_a2a_agent.py b/tests/unittests/a2a/agent/test_remote_a2a_agent.py index 7fb8aedb5e..cc08f6da80 100644 --- a/tests/unittests/a2a/agent/test_remote_a2a_agent.py +++ b/tests/unittests/a2a/agent/test_remote_a2a_agent.py @@ -14,6 +14,7 @@ import copy import json +import os from pathlib import Path import tempfile import threading @@ -44,6 +45,7 @@ from google.adk.a2a.agent import RequestInterceptor import google.adk.a2a.agent._remote_a2a_agent as remote_a2a_agent from google.adk.a2a.agent.config import A2aRemoteAgentConfig +from google.adk.a2a.agent.config import ADK_A2A_ALLOW_INSECURE_HTTP from google.adk.a2a.agent.utils import execute_after_request_interceptors from google.adk.a2a.agent.utils import execute_before_card_request_interceptors from google.adk.a2a.agent.utils import execute_before_request_interceptors @@ -385,6 +387,54 @@ def test_init_with_custom_timeout(self): assert agent._timeout == 300.0 + def test_init_allow_insecure_http_default(self): + agent = RemoteA2aAgent( + name="test_agent", agent_card=create_test_agent_card() + ) + assert agent.allow_insecure_http is False + assert agent._config.allow_insecure_http is False + + def test_init_allow_insecure_http_param(self): + """Test allow_insecure_http can be explicitly enabled via parameter.""" + agent = RemoteA2aAgent( + name="test_agent", + agent_card=create_test_agent_card(), + allow_insecure_http=True, + ) + assert agent.allow_insecure_http is True + assert agent._config.allow_insecure_http is True + + def test_init_allow_insecure_http_from_config(self): + config = A2aRemoteAgentConfig(allow_insecure_http=True) + agent = RemoteA2aAgent( + name="test_agent", + agent_card=create_test_agent_card(), + config=config, + ) + assert agent.allow_insecure_http is True + assert agent._config.allow_insecure_http is True + + def test_init_allow_insecure_http_from_env_var(self): + + with patch.dict(os.environ, {ADK_A2A_ALLOW_INSECURE_HTTP: "1"}): + agent = RemoteA2aAgent( + name="test_agent", agent_card=create_test_agent_card() + ) + assert agent.allow_insecure_http is True + assert agent._config.allow_insecure_http is True + + def test_a2a_remote_agent_config_allow_insecure_http_env_var(self): + + assert A2aRemoteAgentConfig().allow_insecure_http is False + + with patch.dict(os.environ, {ADK_A2A_ALLOW_INSECURE_HTTP: "1"}): + assert A2aRemoteAgentConfig().allow_insecure_http is True + + assert ( + A2aRemoteAgentConfig(allow_insecure_http=False).allow_insecure_http + is False + ) + class TestRemoteA2aAgentResolution: """Test agent card resolution functionality.""" @@ -597,6 +647,68 @@ async def test_resolve_agent_card_allows_loopback_http_source(self): assert await agent._resolve_agent_card(Mock()) == self.agent_card + @pytest.mark.asyncio + async def test_resolve_agent_card_allows_non_loopback_http_when_opted_in( + self, + ): + """Plain http is allowed for non-loopback host when allow_insecure_http=True.""" + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + allow_insecure_http=True, + ) + + with patch.object(agent, "_ensure_httpx_client") as mock_ensure_client: + mock_ensure_client.return_value = AsyncMock() + with patch( + "google.adk.a2a.agent._remote_a2a_agent.A2ACardResolver" + ) as mock_resolver_class: + mock_resolver = AsyncMock() + mock_resolver.get_agent_card.return_value = self.agent_card + mock_resolver_class.return_value = mock_resolver + + assert await agent._resolve_agent_card(Mock()) == self.agent_card + + @pytest.mark.asyncio + async def test_resolve_agent_card_allows_non_loopback_http_via_config(self): + """Plain http is allowed when config.allow_insecure_http is True.""" + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + config=A2aRemoteAgentConfig(allow_insecure_http=True), + ) + + with patch.object(agent, "_ensure_httpx_client") as mock_ensure_client: + mock_ensure_client.return_value = AsyncMock() + with patch( + "google.adk.a2a.agent._remote_a2a_agent.A2ACardResolver" + ) as mock_resolver_class: + mock_resolver = AsyncMock() + mock_resolver.get_agent_card.return_value = self.agent_card + mock_resolver_class.return_value = mock_resolver + + assert await agent._resolve_agent_card(Mock()) == self.agent_card + + @pytest.mark.asyncio + async def test_resolve_agent_card_allows_non_loopback_http_via_env_var(self): + """Plain http is allowed when ADK_A2A_ALLOW_INSECURE_HTTP=1.""" + with patch.dict(os.environ, {ADK_A2A_ALLOW_INSECURE_HTTP: "1"}): + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + ) + + with patch.object(agent, "_ensure_httpx_client") as mock_ensure_client: + mock_ensure_client.return_value = AsyncMock() + with patch( + "google.adk.a2a.agent._remote_a2a_agent.A2ACardResolver" + ) as mock_resolver_class: + mock_resolver = AsyncMock() + mock_resolver.get_agent_card.return_value = self.agent_card + mock_resolver_class.return_value = mock_resolver + + assert await agent._resolve_agent_card(Mock()) == self.agent_card + @pytest.mark.asyncio async def test_card_request_interceptors_injects_headers(self): """Header provider headers (from session state) are sent for the card.""" @@ -1037,6 +1149,84 @@ async def test_validate_agent_card_allows_local_development_http(self): create_test_agent_card(url="http://localhost:8000/a2a") ) + @pytest.mark.asyncio + async def test_validate_agent_card_rejects_insecure_http_rpc_target_by_default( + self, + ): + """Card with non-loopback HTTP RPC URL is rejected by default.""" + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + ) + + with pytest.raises(AgentCardResolutionError, match="must use https"): + agent._validate_card_rpc_targets( + create_test_agent_card(url="http://mesh-service.internal:8080/rpc") + ) + + @pytest.mark.asyncio + async def test_validate_agent_card_allows_insecure_http_rpc_target_when_opted_in( + self, + ): + """Non-loopback HTTP RPC target succeeds when allow_insecure_http=True.""" + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + allow_insecure_http=True, + ) + + await agent._validate_agent_card( + create_test_agent_card(url="http://mesh-service.internal:8080/rpc") + ) + + @pytest.mark.asyncio + async def test_validate_agent_card_allows_insecure_http_rpc_target_via_config( + self, + ): + """Non-loopback HTTP RPC target succeeds when config.allow_insecure_http=True.""" + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + config=A2aRemoteAgentConfig(allow_insecure_http=True), + ) + + await agent._validate_agent_card( + create_test_agent_card(url="http://mesh-service.internal:8080/rpc") + ) + + @pytest.mark.asyncio + async def test_validate_agent_card_allows_insecure_http_rpc_target_via_env_var( + self, + ): + """Non-loopback HTTP RPC target succeeds when ADK_A2A_ALLOW_INSECURE_HTTP=1.""" + with patch.dict(os.environ, {ADK_A2A_ALLOW_INSECURE_HTTP: "1"}): + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + ) + + await agent._validate_agent_card( + create_test_agent_card(url="http://mesh-service.internal:8080/rpc") + ) + + @pytest.mark.asyncio + async def test_validate_agent_card_insecure_http_still_enforces_same_origin( + self, + ): + """Even with allow_insecure_http=True, off-origin RPC target is rejected.""" + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + allow_insecure_http=True, + ) + + with pytest.raises( + AgentCardResolutionError, match="must have the same origin" + ): + await agent._validate_agent_card( + create_test_agent_card(url="http://other-service.internal:8080/rpc") + ) + @pytest.mark.asyncio async def test_validate_agent_card_file_source_is_not_origin_checked(self): """A card read from a local file is configuration, not remote data."""