fix: constrain the RPC targets of a network-fetched A2A agent card (v1)

Before this change, `RemoteA2aAgent` accepted whatever RPC URL an agent card
advertised. Validation only checked that the card's `url` was non-empty and
parsed into a scheme and a netloc, so a card fetched over the network could
point the conversation, and any auth the client attaches to it, at any host,
over cleartext if it liked. Endpoints beyond the top-level `url` were not
looked at at all, even though the client factory negotiates its transport
across the card's whole interface list and can pick one of them.

Now, when the card was fetched over http or https, every URL it advertises
must use https and match the origin the card came from, comparing scheme,
host and port. Plain http stays allowed on a loopback host, which is the
shape the A2A local-development helpers emit. A card supplied as an
`AgentCard` object or read from a local file is left alone, since it did not
come off the network.

Behaviour change: a deployment that serves its agent card from one host and
its RPC endpoint from another now raises `AgentCardResolutionError` instead
of connecting, and so does one that advertises a cleartext endpoint off
loopback. A differing port counts as a differing origin. Because the origin
is compared against the URL that was configured rather than the one that
finally answered, a card reached through a redirect to another origin is
rejected as well. Passing the `AgentCard` object directly or pointing
`agent_card` at a local file remains available for those cases.

The upstream change routes endpoint enumeration through a compatibility
module that straddles `a2a-sdk` 0.3.x and 1.x. This branch pins
`a2a-sdk>=0.3.4,<0.4`, so the 0.3.x behaviour is implemented directly as a
private helper in `remote_a2a_agent.py` instead.
This commit is contained in:
George Weale
2026-08-17 22:59:08 +00:00
parent c71ade0c96
commit 301efcf8f0
2 changed files with 285 additions and 0 deletions
+102
View File
@@ -15,6 +15,7 @@
from __future__ import annotations
import dataclasses
import ipaddress
import json
import logging
from pathlib import Path
@@ -90,9 +91,62 @@ __all__ = [
A2A_METADATA_PREFIX = "a2a:"
DEFAULT_TIMEOUT = 600.0
_DEFAULT_PORTS = {"http": 80, "https": 443}
logger = logging.getLogger("google_adk." + __name__)
def _agent_card_rpc_urls(card: AgentCard) -> list[str]:
"""Returns every URL on a card that a client may send RPC traffic to.
The client factory negotiates the endpoint across the card's whole interface
list, so the endpoint it picks is not necessarily the card's top-level
``url``. Callers that need to constrain the destination must consider all of
them: the top-level ``url`` followed by every
``additional_interfaces[i].url``, in card order.
"""
candidates = [card.url]
candidates.extend(iface.url for iface in card.additional_interfaces or [])
urls: list[str] = []
for url in candidates:
if url and url not in urls:
urls.append(url)
return urls
def _is_loopback_host(hostname: Optional[str]) -> bool:
"""Returns whether a hostname names the local machine.
Covers ``localhost`` and the reserved ``*.localhost`` names as well as any
literal loopback address, so the local-development pattern the A2A helpers
emit -- a plain-http card served from ``localhost`` -- keeps working.
"""
if not hostname:
return False
host = hostname.strip("[]").lower()
if host == "localhost" or host.endswith(".localhost"):
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def _url_origin(url: str) -> tuple[str, str, Optional[int]]:
"""Returns the ``(scheme, host, port)`` origin triple for a URL.
Raises:
ValueError: If the URL carries a malformed port.
"""
parsed = urlparse(url)
scheme = parsed.scheme.lower()
return (
scheme,
(parsed.hostname or "").lower(),
(parsed.port or _DEFAULT_PORTS.get(scheme)),
)
@a2a_experimental
class AgentCardResolutionError(Exception):
"""Raised when agent card resolution fails."""
@@ -326,6 +380,54 @@ class RemoteA2aAgent(BaseAgent):
f"Invalid RPC URL in agent card: {agent_card.url}, error: {e}"
) from e
self._validate_card_rpc_targets(agent_card)
def _validate_card_rpc_targets(self, agent_card: AgentCard) -> None:
"""Constrains where a card fetched over the network may aim RPC traffic.
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.
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.
"""
source = self._agent_card_source
if not source or not source.startswith(("http://", "https://")):
return
try:
source_origin = _url_origin(source)
except ValueError as e:
raise AgentCardResolutionError(
f"Invalid agent card source URL: {source}, error: {e}"
) from e
for card_url in _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
):
raise AgentCardResolutionError(
"Agent card RPC URL must use https, or http on a loopback host:"
f" {card_url}"
)
try:
card_origin = _url_origin(card_url)
except ValueError as e:
raise AgentCardResolutionError(
f"Invalid RPC URL in agent card: {card_url}, error: {e}"
) from e
if card_origin != source_origin:
raise AgentCardResolutionError(
"Agent card RPC URL must have the same origin as the location the"
f" card was fetched from ({source}): {card_url}"
)
async def _ensure_resolved(self) -> None:
"""Ensures agent card is resolved, RPC URL is determined, and A2A client is initialized."""
if self._is_resolved and self._a2a_client:
@@ -26,6 +26,7 @@ from a2a.client.client_factory import ClientFactory
from a2a.client.middleware import ClientCallContext
from a2a.types import AgentCapabilities
from a2a.types import AgentCard
from a2a.types import AgentInterface
from a2a.types import AgentSkill
from a2a.types import Artifact
from a2a.types import Message as A2AMessage
@@ -58,6 +59,7 @@ def create_test_agent_card(
name: str = "test-agent",
url: str = "https://example.com/rpc",
description: str = "Test agent",
**kwargs,
) -> AgentCard:
"""Create a test AgentCard with all required fields."""
return AgentCard(
@@ -76,6 +78,25 @@ def create_test_agent_card(
tags=["test"],
)
],
**kwargs,
)
def _make_multi_interface_card(interfaces) -> AgentCard:
"""Build a card offering several RPC endpoints.
``interfaces`` is a list of ``(url, transport)`` pairs. The first pair is the
card's primary endpoint, becoming the top-level ``url`` and
``preferred_transport``; the rest land in ``additional_interfaces``.
"""
(primary_url, primary_transport), *extra = interfaces
return create_test_agent_card(
url=primary_url,
preferred_transport=primary_transport,
additional_interfaces=[
AgentInterface(url=url, transport=transport)
for url, transport in extra
],
)
@@ -432,6 +453,168 @@ class TestRemoteA2aAgentResolution:
with pytest.raises(AgentCardResolutionError, match="Invalid RPC URL"):
await agent._validate_agent_card(invalid_card)
@pytest.mark.asyncio
async def test_validate_agent_card_accepts_same_origin_https_rpc_url(self):
"""A fetched card pointing back at its own origin is accepted."""
agent = RemoteA2aAgent(
name="test_agent", agent_card="https://example.com/agent.json"
)
# Should not raise any exception.
await agent._validate_agent_card(
create_test_agent_card(url="https://example.com/rpc")
)
@pytest.mark.asyncio
async def test_validate_agent_card_rejects_cross_origin_rpc_url(self):
"""A fetched card cannot redirect RPC traffic to an unrelated host."""
agent = RemoteA2aAgent(
name="test_agent", agent_card="https://example.com/agent.json"
)
with pytest.raises(AgentCardResolutionError, match="same origin"):
await agent._validate_agent_card(
create_test_agent_card(url="https://attacker.example.net/rpc")
)
@pytest.mark.asyncio
async def test_validate_agent_card_rejects_plain_http_rpc_url(self):
"""A fetched card cannot downgrade RPC traffic to cleartext."""
agent = RemoteA2aAgent(
name="test_agent", agent_card="https://example.com/agent.json"
)
with pytest.raises(AgentCardResolutionError, match="must use https"):
await agent._validate_agent_card(
create_test_agent_card(url="http://example.com/rpc")
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"rpc_url",
[
"http://127.0.0.1:8080/rpc",
"http://[::1]:8080/rpc",
"http://169.254.169.254/rpc",
"http://metadata.internal/rpc",
],
)
async def test_validate_agent_card_rejects_internal_rpc_url(self, rpc_url):
"""A fetched card cannot aim RPC traffic at host-local or internal hosts."""
agent = RemoteA2aAgent(
name="test_agent", agent_card="https://example.com/agent.json"
)
with pytest.raises(AgentCardResolutionError):
await agent._validate_agent_card(create_test_agent_card(url=rpc_url))
@pytest.mark.asyncio
async def test_validate_agent_card_allows_local_development_http(self):
"""Plain http stays allowed for a same-origin loopback card."""
agent = RemoteA2aAgent(
name="test_agent",
agent_card="http://localhost:8000/.well-known/agent.json",
)
# Should not raise any exception.
await agent._validate_agent_card(
create_test_agent_card(url="http://localhost:8000/a2a")
)
@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."""
agent = RemoteA2aAgent(name="test_agent", agent_card="/path/to/agent.json")
# Should not raise any exception.
await agent._validate_agent_card(
create_test_agent_card(url="http://internal-host:8080/rpc")
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"interfaces",
[
# A second interface on the transport the client already prefers
# displaces the benign endpoint during transport negotiation.
[
("https://example.com/rpc", "JSONRPC"),
("http://169.254.169.254/", "JSONRPC"),
],
# The primary endpoint advertises a transport the client cannot
# speak, so negotiation falls through to the second interface.
[
("https://example.com/rpc", "GRPC"),
("http://127.0.0.1:9000/", "HTTP+JSON"),
],
],
ids=["displaces_primary", "primary_transport_unsupported"],
)
async def test_validate_agent_card_rejects_off_origin_extra_interface(
self, interfaces
):
"""Every endpoint the card offers is constrained, not just the first."""
agent = RemoteA2aAgent(
name="test_agent", agent_card="https://example.com/agent.json"
)
with pytest.raises(AgentCardResolutionError):
await agent._validate_agent_card(_make_multi_interface_card(interfaces))
@pytest.mark.asyncio
async def test_validate_agent_card_accepts_same_origin_extra_interface(self):
"""A card may still offer several endpoints on its own origin."""
agent = RemoteA2aAgent(
name="test_agent", agent_card="https://example.com/agent.json"
)
# Should not raise any exception.
await agent._validate_agent_card(
_make_multi_interface_card([
("https://example.com/rpc", "JSONRPC"),
("https://example.com/rest", "HTTP+JSON"),
])
)
@pytest.mark.asyncio
async def test_ensure_resolved_rejects_off_origin_fetched_card(self):
"""Resolution from a URL is guarded, not just direct validation.
The origin is compared against the URL that was configured, so a card
served by another origin -- including one reached through a redirect --
does not resolve.
"""
agent = RemoteA2aAgent(
name="test_agent", agent_card="https://example.com/agent.json"
)
with patch.object(agent, "_ensure_httpx_client") as mock_ensure_client:
mock_ensure_client.return_value = AsyncMock()
with patch(
"google.adk.agents.remote_a2a_agent.A2ACardResolver"
) as mock_resolver_class:
mock_resolver = AsyncMock()
mock_resolver.get_agent_card.return_value = create_test_agent_card(
url="https://elsewhere.example.net/rpc"
)
mock_resolver_class.return_value = mock_resolver
with pytest.raises(AgentCardResolutionError, match="same origin"):
await agent._ensure_resolved()
def test_agent_card_rpc_urls_lists_every_endpoint(self):
"""Validation enumerates every endpoint on the card, in card order."""
card = _make_multi_interface_card([
("https://example.com/rpc", "JSONRPC"),
("https://example.com/rest", "HTTP+JSON"),
])
assert remote_a2a_agent._agent_card_rpc_urls(card) == [
"https://example.com/rpc",
"https://example.com/rest",
]
@pytest.mark.asyncio
async def test_ensure_resolved_with_direct_agent_card(self):
"""Test _ensure_resolved with direct agent card."""