Skip to content

Commit 16cbb7d

Browse files
GWealecopybara-github
authored andcommitted
fix: constrain the RPC targets of a network-fetched A2A agent card
Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 956644793
1 parent 0f738a5 commit 16cbb7d

3 files changed

Lines changed: 278 additions & 0 deletions

File tree

src/google/adk/a2a/_compat.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,33 @@ def agent_card_url(
564564
return getattr(card, "url", None)
565565

566566

567+
def agent_card_rpc_urls(card: AgentCard) -> list[str]:
568+
"""Returns every URL on a card that a client may send RPC traffic to.
569+
570+
``agent_card_url`` reports the single endpoint a given protocol binding
571+
resolves to, but the client factory negotiates the endpoint across the
572+
card's whole interface list, so it can pick a URL that helper never returns.
573+
Callers that need to constrain the destination must consider all of them.
574+
575+
1.x: every ``supported_interfaces[i].url``, in card order.
576+
0.3.x: the top-level ``url`` followed by every
577+
``additional_interfaces[i].url``.
578+
"""
579+
if IS_A2A_V1:
580+
candidates = [iface.url for iface in card.supported_interfaces]
581+
else:
582+
candidates = [getattr(card, "url", None)]
583+
candidates.extend(
584+
iface.url
585+
for iface in getattr(card, "additional_interfaces", None) or []
586+
)
587+
urls: list[str] = []
588+
for url in candidates:
589+
if url and url not in urls:
590+
urls.append(url)
591+
return urls
592+
593+
567594
# -----------------------------------------------------------------------------
568595
# Stream-item normalization
569596
# -----------------------------------------------------------------------------

src/google/adk/agents/remote_a2a_agent.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
import ipaddress
1718
import json
1819
import logging
1920
from pathlib import Path
@@ -84,9 +85,44 @@
8485
A2A_METADATA_PREFIX = "a2a:"
8586
DEFAULT_TIMEOUT = 600.0
8687

88+
_DEFAULT_PORTS = {"http": 80, "https": 443}
89+
8790
logger = logging.getLogger("google_adk." + __name__)
8891

8992

93+
def _is_loopback_host(hostname: Optional[str]) -> bool:
94+
"""Returns whether a hostname names the local machine.
95+
96+
Covers ``localhost`` and the reserved ``*.localhost`` names as well as any
97+
literal loopback address, so the local-development pattern the A2A helpers
98+
emit -- a plain-http card served from ``localhost`` -- keeps working.
99+
"""
100+
if not hostname:
101+
return False
102+
host = hostname.strip("[]").lower()
103+
if host == "localhost" or host.endswith(".localhost"):
104+
return True
105+
try:
106+
return ipaddress.ip_address(host).is_loopback
107+
except ValueError:
108+
return False
109+
110+
111+
def _url_origin(url: str) -> tuple[str, str, Optional[int]]:
112+
"""Returns the ``(scheme, host, port)`` origin triple for a URL.
113+
114+
Raises:
115+
ValueError: If the URL carries a malformed port.
116+
"""
117+
parsed = urlparse(url)
118+
scheme = parsed.scheme.lower()
119+
return (
120+
scheme,
121+
(parsed.hostname or "").lower(),
122+
(parsed.port or _DEFAULT_PORTS.get(scheme)),
123+
)
124+
125+
90126
@a2a_experimental
91127
class AgentCardResolutionError(Exception):
92128
"""Raised when agent card resolution fails."""
@@ -320,6 +356,54 @@ async def _validate_agent_card(self, agent_card: AgentCard) -> None:
320356
f"Invalid RPC URL in agent card: {card_url}, error: {e}"
321357
) from e
322358

359+
self._validate_card_rpc_targets(agent_card)
360+
361+
def _validate_card_rpc_targets(self, agent_card: AgentCard) -> None:
362+
"""Constrains where a card fetched over the network may aim RPC traffic.
363+
364+
Every URL the card offers is checked, not only the one this ADK version
365+
would select, because the client factory negotiates the endpoint across
366+
the card's whole interface list. Each must be https and share the origin
367+
the card was fetched from; plain http stays allowed on a loopback host,
368+
the local-development shape the A2A helpers emit.
369+
370+
A card passed in directly or read from a local file did not come off the
371+
network here, so its target is left to the caller.
372+
"""
373+
source = self._agent_card_source
374+
if not source or not source.startswith(("http://", "https://")):
375+
return
376+
377+
try:
378+
source_origin = _url_origin(source)
379+
except ValueError as e:
380+
raise AgentCardResolutionError(
381+
f"Invalid agent card source URL: {source}, error: {e}"
382+
) from e
383+
384+
for card_url in _compat.agent_card_rpc_urls(agent_card):
385+
parsed_card = urlparse(card_url)
386+
if parsed_card.scheme.lower() != "https" and not _is_loopback_host(
387+
parsed_card.hostname
388+
):
389+
raise AgentCardResolutionError(
390+
"Agent card RPC URL must use https, or http on a loopback host:"
391+
f" {card_url}"
392+
)
393+
394+
try:
395+
card_origin = _url_origin(card_url)
396+
except ValueError as e:
397+
raise AgentCardResolutionError(
398+
f"Invalid RPC URL in agent card: {card_url}, error: {e}"
399+
) from e
400+
401+
if card_origin != source_origin:
402+
raise AgentCardResolutionError(
403+
"Agent card RPC URL must have the same origin as the location the"
404+
f" card was fetched from ({source}): {card_url}"
405+
)
406+
323407
async def _ensure_resolved(
324408
self, ctx: Optional[InvocationContext] = None
325409
) -> A2AClient:

tests/unittests/agents/test_remote_a2a_agent.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from a2a.client.client_factory import ClientFactory
2727
from a2a.types import AgentCapabilities
2828
from a2a.types import AgentCard
29+
from a2a.types import AgentInterface
2930
from a2a.types import AgentSkill
3031
from a2a.types import Artifact
3132
from a2a.types import Message as A2AMessage
@@ -165,6 +166,37 @@ def create_test_agent_card(
165166
)
166167

167168

169+
def _make_multi_interface_card(interfaces) -> AgentCard:
170+
"""Build a card offering several RPC endpoints, version-agnostically.
171+
172+
``interfaces`` is a list of ``(url, transport)`` pairs; the first pair is the
173+
card's primary endpoint. On 1.x every pair becomes a ``supported_interfaces``
174+
entry; on 0.3.x the first pair is the top-level ``url``/``preferredTransport``
175+
and the rest land in ``additional_interfaces``.
176+
"""
177+
if _compat.IS_A2A_V1:
178+
return _compat.parse_agent_card({
179+
"name": "test-agent",
180+
"description": "Test agent",
181+
"version": "1.0",
182+
"supported_interfaces": [
183+
{"url": url, "protocol_binding": transport}
184+
for url, transport in interfaces
185+
],
186+
"default_input_modes": ["text/plain"],
187+
"default_output_modes": ["text/plain"],
188+
})
189+
(primary_url, primary_transport), *extra = interfaces
190+
return _make_agent_card(
191+
url=primary_url,
192+
preferred_transport=primary_transport,
193+
additional_interfaces=[
194+
AgentInterface(url=url, transport=transport)
195+
for url, transport in extra
196+
],
197+
)
198+
199+
168200
class TestRemoteA2aAgentInit:
169201
"""Test RemoteA2aAgent initialization and validation."""
170202

@@ -784,6 +816,141 @@ async def test_validate_agent_card_invalid_url(self):
784816
with pytest.raises(AgentCardResolutionError, match="Invalid RPC URL"):
785817
await agent._validate_agent_card(invalid_card)
786818

819+
@pytest.mark.asyncio
820+
async def test_validate_agent_card_accepts_same_origin_https_rpc_url(self):
821+
"""A fetched card pointing back at its own origin is accepted."""
822+
agent = RemoteA2aAgent(
823+
name="test_agent", agent_card="https://example.com/agent.json"
824+
)
825+
826+
# Should not raise any exception.
827+
await agent._validate_agent_card(
828+
create_test_agent_card(url="https://example.com/rpc")
829+
)
830+
831+
@pytest.mark.asyncio
832+
async def test_validate_agent_card_rejects_cross_origin_rpc_url(self):
833+
"""A fetched card cannot redirect RPC traffic to an unrelated host."""
834+
agent = RemoteA2aAgent(
835+
name="test_agent", agent_card="https://example.com/agent.json"
836+
)
837+
838+
with pytest.raises(AgentCardResolutionError, match="same origin"):
839+
await agent._validate_agent_card(
840+
create_test_agent_card(url="https://attacker.example.net/rpc")
841+
)
842+
843+
@pytest.mark.asyncio
844+
async def test_validate_agent_card_rejects_plain_http_rpc_url(self):
845+
"""A fetched card cannot downgrade RPC traffic to cleartext."""
846+
agent = RemoteA2aAgent(
847+
name="test_agent", agent_card="https://example.com/agent.json"
848+
)
849+
850+
with pytest.raises(AgentCardResolutionError, match="must use https"):
851+
await agent._validate_agent_card(
852+
create_test_agent_card(url="http://example.com/rpc")
853+
)
854+
855+
@pytest.mark.asyncio
856+
@pytest.mark.parametrize(
857+
"rpc_url",
858+
[
859+
"http://127.0.0.1:8080/rpc",
860+
"http://[::1]:8080/rpc",
861+
"http://169.254.169.254/rpc",
862+
"http://metadata.internal/rpc",
863+
],
864+
)
865+
async def test_validate_agent_card_rejects_internal_rpc_url(self, rpc_url):
866+
"""A fetched card cannot aim RPC traffic at host-local or internal hosts."""
867+
agent = RemoteA2aAgent(
868+
name="test_agent", agent_card="https://example.com/agent.json"
869+
)
870+
871+
with pytest.raises(AgentCardResolutionError):
872+
await agent._validate_agent_card(create_test_agent_card(url=rpc_url))
873+
874+
@pytest.mark.asyncio
875+
async def test_validate_agent_card_allows_local_development_http(self):
876+
"""Plain http stays allowed for a same-origin loopback card."""
877+
agent = RemoteA2aAgent(
878+
name="test_agent",
879+
agent_card="http://localhost:8000/.well-known/agent.json",
880+
)
881+
882+
# Should not raise any exception.
883+
await agent._validate_agent_card(
884+
create_test_agent_card(url="http://localhost:8000/a2a")
885+
)
886+
887+
@pytest.mark.asyncio
888+
async def test_validate_agent_card_file_source_is_not_origin_checked(self):
889+
"""A card read from a local file is configuration, not remote data."""
890+
agent = RemoteA2aAgent(name="test_agent", agent_card="/path/to/agent.json")
891+
892+
# Should not raise any exception.
893+
await agent._validate_agent_card(
894+
create_test_agent_card(url="http://internal-host:8080/rpc")
895+
)
896+
897+
@pytest.mark.asyncio
898+
@pytest.mark.parametrize(
899+
"interfaces",
900+
[
901+
# A second interface on the transport the client already prefers
902+
# displaces the benign endpoint during transport negotiation.
903+
[
904+
("https://example.com/rpc", "JSONRPC"),
905+
("http://169.254.169.254/", "JSONRPC"),
906+
],
907+
# The primary endpoint advertises a transport the client cannot
908+
# speak, so negotiation falls through to the second interface.
909+
[
910+
("https://example.com/rpc", "GRPC"),
911+
("http://127.0.0.1:9000/", "HTTP+JSON"),
912+
],
913+
],
914+
ids=["displaces_primary", "primary_transport_unsupported"],
915+
)
916+
async def test_validate_agent_card_rejects_off_origin_extra_interface(
917+
self, interfaces
918+
):
919+
"""Every endpoint the card offers is constrained, not just the first."""
920+
agent = RemoteA2aAgent(
921+
name="test_agent", agent_card="https://example.com/agent.json"
922+
)
923+
924+
with pytest.raises(AgentCardResolutionError):
925+
await agent._validate_agent_card(_make_multi_interface_card(interfaces))
926+
927+
@pytest.mark.asyncio
928+
async def test_validate_agent_card_accepts_same_origin_extra_interface(self):
929+
"""A card may still offer several endpoints on its own origin."""
930+
agent = RemoteA2aAgent(
931+
name="test_agent", agent_card="https://example.com/agent.json"
932+
)
933+
934+
# Should not raise any exception.
935+
await agent._validate_agent_card(
936+
_make_multi_interface_card([
937+
("https://example.com/rpc", "JSONRPC"),
938+
("https://example.com/rest", "HTTP+JSON"),
939+
])
940+
)
941+
942+
def test_agent_card_rpc_urls_lists_every_endpoint(self):
943+
"""Validation enumerates every endpoint on the card, in card order."""
944+
card = _make_multi_interface_card([
945+
("https://example.com/rpc", "JSONRPC"),
946+
("https://example.com/rest", "HTTP+JSON"),
947+
])
948+
949+
assert _compat.agent_card_rpc_urls(card) == [
950+
"https://example.com/rpc",
951+
"https://example.com/rest",
952+
]
953+
787954
@pytest.mark.asyncio
788955
async def test_ensure_resolved_with_direct_agent_card(self):
789956
"""Test _ensure_resolved with direct agent card."""

0 commit comments

Comments
 (0)