Skip to content

Commit fe30ebb

Browse files
GWealecopybara-github
authored andcommitted
fix(a2a): require https for a non-loopback agent card URL
Co-authored-by: George Weale <[email protected]> PiperOrigin-RevId: 977966430
1 parent c274ac3 commit fe30ebb

2 files changed

Lines changed: 63 additions & 0 deletions

File tree

src/google/adk/agents/remote_a2a_agent.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,19 @@ async def _resolve_agent_card(
932932

933933
# Determine if source is URL or file path
934934
if agent_card_source.startswith(("http://", "https://")):
935+
# The card request interceptors attach the credential resolved for this
936+
# invocation, so the scheme is checked before the fetch rather than with
937+
# the card's RPC targets afterwards -- by then the credential has already
938+
# gone out on the wire. Plain http stays allowed on a loopback host, the
939+
# same carve-out `_validate_card_rpc_targets` applies.
940+
parsed_source = urlparse(agent_card_source)
941+
if parsed_source.scheme.lower() != "https" and not _is_loopback_host(
942+
parsed_source.hostname
943+
):
944+
raise AgentCardResolutionError(
945+
"Agent card URL must use https, or http on a loopback host:"
946+
f" {agent_card_source}"
947+
)
935948
return await self._resolve_agent_card_from_url(agent_card_source, ctx)
936949
else:
937950
return await self._resolve_agent_card_from_file(agent_card_source)

tests/unittests/agents/test_remote_a2a_agent.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,56 @@ async def test_resolve_agent_card_from_url_invalid_url(self):
513513
with pytest.raises(AgentCardResolutionError, match="Invalid URL format"):
514514
await agent._resolve_agent_card_from_url("invalid-url", Mock())
515515

516+
@pytest.mark.asyncio
517+
async def test_resolve_agent_card_rejects_plain_http_source(self):
518+
"""A cleartext card source is refused before the credential is attached."""
519+
520+
async def provider(ctx):
521+
return A2aCardRequestConfig(headers={"Authorization": "Bearer abc"})
522+
523+
agent = RemoteA2aAgent(
524+
name="test_agent",
525+
agent_card="http://example.com/agent.json",
526+
config=A2aRemoteAgentConfig(
527+
card_request_interceptors=[
528+
CardRequestInterceptor(before_request=provider)
529+
]
530+
),
531+
)
532+
533+
with patch.object(agent, "_ensure_httpx_client") as mock_ensure_client:
534+
mock_ensure_client.return_value = AsyncMock()
535+
with patch(
536+
"google.adk.agents.remote_a2a_agent.A2ACardResolver"
537+
) as mock_resolver_class:
538+
mock_resolver = AsyncMock()
539+
mock_resolver.get_agent_card.return_value = self.agent_card
540+
mock_resolver_class.return_value = mock_resolver
541+
542+
with pytest.raises(AgentCardResolutionError, match="must use https"):
543+
await agent._resolve_agent_card(Mock())
544+
545+
# No fetch was attempted, so the credential never went out over cleartext.
546+
mock_resolver_class.assert_not_called()
547+
548+
@pytest.mark.asyncio
549+
async def test_resolve_agent_card_allows_loopback_http_source(self):
550+
"""Plain http stays allowed for the local-development card source."""
551+
agent = RemoteA2aAgent(
552+
name="test_agent", agent_card="http://localhost:8000/agent.json"
553+
)
554+
555+
with patch.object(agent, "_ensure_httpx_client") as mock_ensure_client:
556+
mock_ensure_client.return_value = AsyncMock()
557+
with patch(
558+
"google.adk.agents.remote_a2a_agent.A2ACardResolver"
559+
) as mock_resolver_class:
560+
mock_resolver = AsyncMock()
561+
mock_resolver.get_agent_card.return_value = self.agent_card
562+
mock_resolver_class.return_value = mock_resolver
563+
564+
assert await agent._resolve_agent_card(Mock()) == self.agent_card
565+
516566
@pytest.mark.asyncio
517567
async def test_card_request_interceptors_injects_headers(self):
518568
"""Header provider headers (from session state) are sent for the card."""

0 commit comments

Comments
 (0)