Skip to content

feat: add composable URL validation utilities#1114

Open
Linux2010 wants to merge 1 commit into
a2aproject:mainfrom
Linux2010:codex/1023-url-validator
Open

feat: add composable URL validation utilities#1114
Linux2010 wants to merge 1 commit into
a2aproject:mainfrom
Linux2010:codex/1023-url-validator

Conversation

@Linux2010

@Linux2010 Linux2010 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds reusable URL validation infrastructure for #1023:

  • UrlValidator parses URLs, optionally resolves hosts, and returns a ResolvedUrl containing pinned IP addresses.
  • UrlValidationRule provides composable async validation hooks.
  • Built-in rules:
    • RequireScheme
    • BlockPrivateNetworks with allow_hosts and allow_cidrs
  • Exports the public utilities from a2a.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.sh
  • uv run pytest
  • uv run pytest --cov=src --cov-report=term-missing

Closes #1023

@Linux2010 Linux2010 requested a review from a team as a code owner June 26, 2026 06:17

@gemini-code-assist gemini-code-assist Bot left a comment

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.

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.

Comment on lines +170 to +172
addresses = tuple(
dict.fromkeys(ipaddress.ip_address(address) for address in resolved)
)

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.

high

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.

Suggested change
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
)
)

Comment on lines +99 to +106
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}.'
)

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

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}.'
                )

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown

🧪 Code Coverage (vs main)

⬇️ Download Full Report

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>
@Linux2010 Linux2010 force-pushed the codex/1023-url-validator branch from 817ab7a to 9ce1bc3 Compare June 26, 2026 06:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Task]: Prepare URL validation infrastructure for agent card and webhook SSRF protection

1 participant