feat: add composable URL validation utilities#1114
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a new composable URL validation utility (url_validator.py) along with comprehensive unit tests. The utility includes rules for enforcing schemes and blocking private networks to prevent SSRF. The review feedback identifies two critical issues: first, a potential runtime crash in _resolve_host if a custom resolver returns already-instantiated IP address objects; second, a security bypass in BlockPrivateNetworks when DNS resolution is disabled, allowing private IP literals to pass validation.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| addresses = tuple( | ||
| dict.fromkeys(ipaddress.ip_address(address) for address in resolved) | ||
| ) |
There was a problem hiding this comment.
The Resolver type alias is defined as returning Sequence[IPAddress | str]. However, ipaddress.ip_address() raises a ValueError if passed an already-instantiated IPv4Address or IPv6Address object. If a custom resolver returns IPAddress objects, _resolve_host will crash with a ValueError at runtime. Checking the type of each address before passing it to ipaddress.ip_address() resolves this issue.
| addresses = tuple( | |
| dict.fromkeys(ipaddress.ip_address(address) for address in resolved) | |
| ) | |
| addresses = tuple( | |
| dict.fromkeys( | |
| addr if isinstance(addr, (ipaddress.IPv4Address, ipaddress.IPv6Address)) | |
| else ipaddress.ip_address(addr) | |
| for addr in resolved | |
| ) | |
| ) |
| for address in url.addresses: | ||
| if any(address in network for network in self._allow_networks): | ||
| continue | ||
| if not address.is_global: | ||
| raise InvalidUrlError( | ||
| f'URL host {host!r} resolves to non-public address ' | ||
| f'{address}.' | ||
| ) |
There was a problem hiding this comment.
If resolve=False is configured on the UrlValidator, url.addresses will be empty. In this case, BlockPrivateNetworks will silently allow any URL, even if the host is a private IP address literal (e.g., http://127.0.0.1/). To ensure robust defense-in-depth and prevent SSRF bypasses, BlockPrivateNetworks should attempt to parse the host as an IP address literal if url.addresses is empty.
addresses = url.addresses
if not addresses and host is not None:
try:
addresses = (ipaddress.ip_address(host),)
except ValueError:
pass
for address in addresses:
if any(address in network for network in self._allow_networks):
continue
if not address.is_global:
raise InvalidUrlError(
f'URL host {host!r} resolves to non-public address '
f'{address}.'
)
🧪 Code Coverage (vs
|
| Base | PR | Delta | |
|---|---|---|---|
| src/a2a/server/events/event_queue_v2.py | 91.19% | 91.71% | 🟢 +0.52% |
| src/a2a/utils/telemetry.py | 90.70% | 91.47% | 🟢 +0.78% |
| src/a2a/utils/url_validator.py (new) | — | 95.65% | — |
| Total | 92.99% | 93.04% | 🟢 +0.05% |
Generated by coverage-comment.yml
Add a reusable UrlValidator foundation for SSRF-sensitive call sites. The validator parses URLs, optionally resolves hosts to pinned IP addresses, and runs composable async rules such as RequireScheme and BlockPrivateNetworks. Includes tests for scheme validation, host resolution, non-public address rejection, allowlists, and resolver failures. Closes a2aproject#1023 Co-Authored-By: Codex <noreply@openai.com>
817ab7a to
9ce1bc3
Compare
Summary
Adds reusable URL validation infrastructure for #1023:
UrlValidatorparses URLs, optionally resolves hosts, and returns aResolvedUrlcontaining pinned IP addresses.UrlValidationRuleprovides composable async validation hooks.RequireSchemeBlockPrivateNetworkswithallow_hostsandallow_cidrsa2a.utils.This PR intentionally stops at the issue scope: it does not wire validation into agent-card fetching or push notification webhooks yet. Those domain-specific integrations can build on this foundation in follow-up work.
Testing
uv run pytest tests/utils/test_url_validator.py -q./scripts/lint.shuv run pytestuv run pytest --cov=src --cov-report=term-missingCloses #1023