From b3d361067a9b8e934aa6fda23c9cf17c5cb1892e 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 | 54 +++- src/google/adk/a2a/agent/config.py | 8 + src/google/adk/agents/remote_a2a_agent.py | 2 + .../a2a/agent/test_remote_a2a_agent.py | 298 ++++++++++++++++++ 5 files changed, 356 insertions(+), 11 deletions(-) diff --git a/src/google/adk/a2a/agent/__init__.py b/src/google/adk/a2a/agent/__init__.py index 0e8fc6cc6dc..6dfbf592502 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 adfcce54638..5b185e85883 100644 --- a/src/google/adk/a2a/agent/_remote_a2a_agent.py +++ b/src/google/adk/a2a/agent/_remote_a2a_agent.py @@ -84,6 +84,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 +96,7 @@ __all__ = [ "A2AClientError", + "ADK_A2A_ALLOW_INSECURE_HTTP", "AGENT_CARD_WELL_KNOWN_PATH", "AgentCardResolutionError", "RemoteA2aAgent", @@ -680,6 +682,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 +717,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,6 +749,7 @@ 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 not use_legacy: @@ -818,6 +826,18 @@ 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 + + @_allow_insecure_http.setter + def _allow_insecure_http(self, value: bool) -> None: + self._allow_insecure_http_param = value + + @property + def allow_insecure_http(self) -> bool: + return self._allow_insecure_http + async def _resolve_auth_credential( self, ctx: InvocationContext ) -> Optional[Event]: @@ -956,14 +976,20 @@ 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:" + "Agent card URL must use https, or http on a loopback host (or set" + f" allow_insecure_http=True / {ADK_A2A_ALLOW_INSECURE_HTTP}=1):" f" {agent_card_source}" ) return await self._resolve_agent_card_from_url(agent_card_source, ctx) @@ -995,9 +1021,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,11 +1041,17 @@ 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:" + "Agent card RPC URL must use https, or http on a loopback host (or" + f" set allow_insecure_http=True / {ADK_A2A_ALLOW_INSECURE_HTTP}=1):" f" {card_url}" ) diff --git a/src/google/adk/a2a/agent/config.py b/src/google/adk/a2a/agent/config.py index e7388190f47..1a198d6c63f 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: diff --git a/src/google/adk/agents/remote_a2a_agent.py b/src/google/adk/agents/remote_a2a_agent.py index 1e3d1d0f112..10053b6f451 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 7fb8aedb5e2..e1ae3cfb225 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,102 @@ 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 False + + def test_init_allow_insecure_http_param_does_not_mutate_shared_config(self): + """Test allow_insecure_http param does not mutate shared config object.""" + shared_config = A2aRemoteAgentConfig() + agent1 = RemoteA2aAgent( + name="agent1", + agent_card=create_test_agent_card(), + config=shared_config, + allow_insecure_http=True, + ) + agent2 = RemoteA2aAgent( + name="agent2", + agent_card=create_test_agent_card(), + config=shared_config, + ) + assert agent1.allow_insecure_http is True + assert agent2.allow_insecure_http is False + assert shared_config.allow_insecure_http is False + + def test_allow_insecure_http_setter_does_not_mutate_shared_config(self): + """Test setter does not mutate shared config object.""" + shared_config = A2aRemoteAgentConfig() + agent = RemoteA2aAgent( + name="test_agent", + agent_card=create_test_agent_card(), + config=shared_config, + ) + agent._allow_insecure_http = True + assert agent.allow_insecure_http is True + assert shared_config.allow_insecure_http is False + + 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 + ) + + def test_agent_allow_insecure_http_explicit_false_config_overrides_env_var( + self, + ): + """Explicit allow_insecure_http=False in config overrides env var at agent level.""" + with patch.dict(os.environ, {ADK_A2A_ALLOW_INSECURE_HTTP: "1"}): + agent_default = RemoteA2aAgent( + name="agent_default", agent_card=create_test_agent_card() + ) + assert agent_default.allow_insecure_http is True + + config = A2aRemoteAgentConfig(allow_insecure_http=False) + agent_opt_out = RemoteA2aAgent( + name="agent_opt_out", + agent_card=create_test_agent_card(), + config=config, + ) + assert agent_opt_out.allow_insecure_http is False + class TestRemoteA2aAgentResolution: """Test agent card resolution functionality.""" @@ -597,6 +695,95 @@ 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_resolve_agent_card_rejects_non_loopback_http_when_config_opts_out_with_env_var( + self, + ): + """Explicit allow_insecure_http=False in config overrides env var during resolution.""" + 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", + config=A2aRemoteAgentConfig(allow_insecure_http=False), + ) + with pytest.raises(AgentCardResolutionError, match="must use https"): + await agent._resolve_agent_card(Mock()) + + @pytest.mark.asyncio + async def test_resolve_agent_card_error_mentions_allow_insecure_http(self): + """Error message mentions allow_insecure_http and ADK_A2A_ALLOW_INSECURE_HTTP.""" + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + ) + with pytest.raises( + AgentCardResolutionError, + match=r"allow_insecure_http=True / ADK_A2A_ALLOW_INSECURE_HTTP=1", + ): + await agent._resolve_agent_card(Mock()) + @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 +1224,117 @@ 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_rejects_http_rpc_target_when_config_opts_out_with_env_var( + self, + ): + """Explicit allow_insecure_http=False in config overrides env var during RPC validation.""" + 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", + config=A2aRemoteAgentConfig(allow_insecure_http=False), + ) + 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_card_rpc_targets_error_mentions_allow_insecure_http( + self, + ): + """RPC target error message mentions allow_insecure_http and ADK_A2A_ALLOW_INSECURE_HTTP.""" + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://localhost:8000/agent.json", + ) + with pytest.raises( + AgentCardResolutionError, + match=r"allow_insecure_http=True / ADK_A2A_ALLOW_INSECURE_HTTP=1", + ): + agent._validate_card_rpc_targets( + create_test_agent_card(url="http://remote-host:8000/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."""