Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/a2a/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
)
from a2a.client.base_client import BaseClient
from a2a.client.card_resolver import A2ACardResolver
from a2a.client.card_validators import (
CardValidator,
InvalidAgentCardError,
reject_non_https_urls,
reject_private_urls,
require_supported_interfaces,
validate_card,
)
from a2a.client.client import (
Client,
ClientCallContext,
Expand All @@ -32,13 +40,19 @@
'AgentCardResolutionError',
'AuthInterceptor',
'BaseClient',
'CardValidator',
'Client',
'ClientCallContext',
'ClientCallInterceptor',
'ClientConfig',
'ClientFactory',
'CredentialService',
'InMemoryContextCredentialStore',
'InvalidAgentCardError',
'create_client',
'minimal_agent_card',
'reject_non_https_urls',
'reject_private_urls',
'require_supported_interfaces',
'validate_card',
]
85 changes: 85 additions & 0 deletions src/a2a/client/card_validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Optional validators for AgentCard instances before client creation."""

from __future__ import annotations

import ipaddress

from collections.abc import Callable
from urllib.parse import urlparse

from a2a.types.a2a_pb2 import AgentCard


CardValidator = Callable[[AgentCard], None]


class InvalidAgentCardError(ValueError):
"""Raised when an AgentCard fails a registered validation hook."""


def _iter_card_urls(card: AgentCard) -> list[str]:
urls = [
interface.url
for interface in card.supported_interfaces
if interface.url
]
if card.documentation_url:
urls.append(card.documentation_url)
if card.icon_url:
urls.append(card.icon_url)
return urls
Comment on lines +20 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The _iter_card_urls helper extracts URLs from the card's supported interfaces, documentation, and icon, but it misses the provider.url field. If an untrusted card specifies a malicious or private URL in provider.url, it will bypass validation. Including card.provider.url ensures all metadata URLs are validated. Note that we only check and append card.provider.url if it is present, to handle cases where the URL might not be set.

Suggested change
def _iter_card_urls(card: AgentCard) -> list[str]:
urls = [
interface.url
for interface in card.supported_interfaces
if interface.url
]
if card.documentation_url:
urls.append(card.documentation_url)
if card.icon_url:
urls.append(card.icon_url)
return urls
def _iter_card_urls(card: AgentCard) -> list[str]:
urls = [
interface.url
for interface in card.supported_interfaces
if interface.url
]
if card.documentation_url:
urls.append(card.documentation_url)
if card.icon_url:
urls.append(card.icon_url)
if card.provider.url:
urls.append(card.provider.url)
return urls
References
  1. When displaying provider information, ensure that the URL is only shown if it is present, even if the specification indicates it should be a required field, to handle cases where the current implementation might allow creation without the URL set.
  2. If a field in a data model (e.g., ServerCallContext.user) is non-optional, avoid adding redundant checks for its existence and rely on the data model's contract.



def reject_non_https_urls(card: AgentCard) -> None:
"""Reject cards whose interface or metadata URLs are not https://."""
for url in _iter_card_urls(card):
if urlparse(url).scheme != 'https':
raise InvalidAgentCardError(
f'Card URL must use https, got: {url!r}'
)


def reject_private_urls(card: AgentCard) -> None:
"""Reject cards whose URLs use private, loopback, or link-local IP hosts."""
for url in _iter_card_urls(card):
_check_not_private(url)


def _check_not_private(url: str) -> None:
parsed = urlparse(url)
host = parsed.hostname
if host is None:
raise InvalidAgentCardError(f'URL has no hostname: {url!r}')
try:
ip = ipaddress.ip_address(host)
except ValueError:
return
Comment on lines +50 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The _check_not_private function checks if the hostname is a private IP address, but it does not reject standard loopback or local hostnames like localhost or .local domains. Since ipaddress.ip_address raises a ValueError for non-IP hostnames, names like localhost or service.local will bypass this check. Explicitly rejecting localhost, .localhost, and .local domains prevents private network access via these hostnames.

Suggested change
host = parsed.hostname
if host is None:
raise InvalidAgentCardError(f'URL has no hostname: {url!r}')
try:
ip = ipaddress.ip_address(host)
except ValueError:
return
host = parsed.hostname
if host is None:
raise InvalidAgentCardError(f'URL has no hostname: {url!r}')
host_lower = host.lower()
if (
host_lower == 'localhost'
or host_lower.endswith('.localhost')
or host_lower.endswith('.local')
):
raise InvalidAgentCardError(
f'URL points to a private or loopback address: {url!r}'
)
try:
ip = ipaddress.ip_address(host)
except ValueError:
return

if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
raise InvalidAgentCardError(
f'URL points to a private or loopback address: {url!r}'
)


def require_supported_interfaces(card: AgentCard) -> None:
"""Reject cards that do not declare at least one supported interface."""
if not card.supported_interfaces:
raise InvalidAgentCardError(
'Card must declare at least one supported interface.'
)
for interface in card.supported_interfaces:
if not interface.url:
raise InvalidAgentCardError(
'Each supported interface must include a URL.'
)
if not interface.protocol_binding:
raise InvalidAgentCardError(
'Each supported interface must include a protocol binding.'
)


def validate_card(
card: AgentCard, validators: list[CardValidator] | None
) -> None:
"""Run registered validators against an AgentCard."""
for validator in validators or []:
validator(card)
9 changes: 8 additions & 1 deletion src/a2a/client/client_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from a2a.client.base_client import BaseClient
from a2a.client.card_resolver import A2ACardResolver
from a2a.client.card_validators import CardValidator, validate_card
from a2a.client.client import Client, ClientConfig
from a2a.client.transports.base import ClientTransport
from a2a.client.transports.jsonrpc import JsonRpcTransport
Expand Down Expand Up @@ -38,13 +39,13 @@
try:
from a2a.client.transports.grpc import GrpcTransport
except ImportError:
GrpcTransport = None # type: ignore # pyright: ignore

Check warning on line 42 in src/a2a/client/client_factory.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

ty (unused-type-ignore-comment)

src/a2a/client/client_factory.py:42:27: unused-type-ignore-comment: Unused blanket `type: ignore` directive help: Remove the unused suppression comment


try:
from a2a.compat.v0_3.grpc_transport import CompatGrpcTransport
except ImportError:
CompatGrpcTransport = None # type: ignore # pyright: ignore

Check warning on line 48 in src/a2a/client/client_factory.py

View workflow job for this annotation

GitHub Actions / Lint Code Base

ty (unused-type-ignore-comment)

src/a2a/client/client_factory.py:48:33: unused-type-ignore-comment: Unused blanket `type: ignore` directive help: Remove the unused suppression comment

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -76,6 +77,7 @@
def __init__(
self,
config: ClientConfig | None = None,
card_validators: list[CardValidator] | None = None,
):
config = config or ClientConfig()
httpx_client = config.httpx_client or httpx.AsyncClient()
Expand All @@ -85,6 +87,7 @@

self._config = config
self._httpx_client = httpx_client
self._card_validators = card_validators or []
self._registry: dict[str, TransportProducer] = {}
self._register_defaults(config.supported_protocol_bindings)

Expand Down Expand Up @@ -319,6 +322,7 @@
If there is no valid matching of the client configuration with the
server configuration, a `ValueError` is raised.
"""
validate_card(card, self._card_validators)
client_set = self._config.supported_protocol_bindings or [
TransportProtocol.JSONRPC
]
Expand Down Expand Up @@ -371,6 +375,7 @@
relative_card_path: str | None = None,
resolver_http_kwargs: dict[str, Any] | None = None,
signature_verifier: Callable[[AgentCard], None] | None = None,
card_validators: list[CardValidator] | None = None,
) -> Client:
"""Create a `Client` for an agent from a URL or `AgentCard`.

Expand All @@ -390,11 +395,13 @@
httpx client when resolving the agent card.
signature_verifier: A callable used to verify the agent card's
signatures.
card_validators: Optional hooks run on resolved cards before a
client is created.

Returns:
A `Client` object.
"""
factory = ClientFactory(client_config)
factory = ClientFactory(client_config, card_validators=card_validators)
if isinstance(agent, str):
return await factory.create_from_url(
agent,
Expand Down
105 changes: 105 additions & 0 deletions tests/client/test_card_validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Tests for optional AgentCard validation hooks."""

import httpx
import pytest

from a2a.client import (
ClientConfig,
ClientFactory,
InvalidAgentCardError,
reject_non_https_urls,
reject_private_urls,
require_supported_interfaces,
)
from a2a.types.a2a_pb2 import (
AgentCapabilities,
AgentCard,
AgentInterface,
)
from a2a.utils.constants import TransportProtocol


def _valid_card(**overrides) -> AgentCard:
card = AgentCard(
name='Test Agent',
description='An agent for testing.',
supported_interfaces=[
AgentInterface(
protocol_binding=TransportProtocol.JSONRPC,
url='https://primary-url.com',
)
],
version='1.0.0',
capabilities=AgentCapabilities(),
skills=[],
default_input_modes=[],
default_output_modes=[],
)
for key, value in overrides.items():
if key == 'supported_interfaces':
card.ClearField('supported_interfaces')
card.supported_interfaces.extend(value)
else:
setattr(card, key, value)
return card


def test_require_supported_interfaces_rejects_empty_card():
card = _valid_card()
card.ClearField('supported_interfaces')

with pytest.raises(InvalidAgentCardError, match='supported interface'):
require_supported_interfaces(card)


def test_reject_non_https_urls_rejects_http_interface():
card = _valid_card(
supported_interfaces=[
AgentInterface(
protocol_binding=TransportProtocol.JSONRPC,
url='http://insecure.example.com',
)
]
)

with pytest.raises(InvalidAgentCardError, match='https'):
reject_non_https_urls(card)


def test_reject_private_urls_rejects_loopback():
card = _valid_card(
supported_interfaces=[
AgentInterface(
protocol_binding=TransportProtocol.JSONRPC,
url='https://127.0.0.1',
)
]
)

with pytest.raises(InvalidAgentCardError, match='private or loopback'):
reject_private_urls(card)
Comment on lines +69 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low

Parameterize the private URL rejection test to cover localhost, .localhost, .local domains, and IPv6 loopback addresses ([::1]) to ensure comprehensive validation coverage.

@pytest.mark.parametrize(
    'url',
    [
        'https://127.0.0.1',
        'https://localhost',
        'https://test.localhost',
        'https://my-service.local',
        'https://[::1]',
    ],
)
def test_reject_private_urls_rejects_loopback(url):
    card = _valid_card(
        supported_interfaces=[
            AgentInterface(
                protocol_binding=TransportProtocol.JSONRPC,
                url=url,
            )
        ]
    )

    with pytest.raises(InvalidAgentCardError, match='private or loopback'):
        reject_private_urls(card)



def test_client_factory_runs_validators_on_create():
card = _valid_card(
supported_interfaces=[
AgentInterface(
protocol_binding=TransportProtocol.JSONRPC,
url='http://insecure.example.com',
)
]
)
factory = ClientFactory(
ClientConfig(httpx_client=httpx.AsyncClient()),
card_validators=[reject_non_https_urls],
)

with pytest.raises(InvalidAgentCardError):
factory.create(card)


def test_client_factory_default_has_no_validation():
card = _valid_card()
factory = ClientFactory(ClientConfig(httpx_client=httpx.AsyncClient()))
client = factory.create(card)
assert client is not None
Loading