Skip to content

Commit 1cba834

Browse files
committed
feat: add URL validation infrastructure for SSRF protection (a2aproject#1023)
Implements composable URL validation rules to protect against SSRF attacks when fetching Agent Cards and processing webhook URLs. - Add InvalidUrlError exception for validation rejections - Add ResolvedUrl frozen dataclass with original URL, parsed result, and resolved IP addresses - Add UrlValidationRule abstract base for composable rules - Add RequireScheme rule to restrict allowed URL schemes (e.g. HTTPS only) - Add BlockPrivateNetworks rule to reject private/loopback addresses with allow_hosts and allow_cidrs exemptions - Add UrlValidator that resolves, pins, and validates URLs through configurable rule chains - Fix: BlockPrivateNetworks checks literal IP hosts even when resolve=False (SSRF bypass prevention) - Fix: _resolve_host handles IPAddress objects from resolver (not just str) - 28 test cases, 96% module coverage Closes a2aproject#1023
1 parent 086d70f commit 1cba834

3 files changed

Lines changed: 710 additions & 0 deletions

File tree

src/a2a/utils/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,26 @@
77
TransportProtocol,
88
)
99
from a2a.utils.proto_utils import to_stream_response
10+
from a2a.utils.url_validator import (
11+
BlockPrivateNetworks,
12+
InvalidUrlError,
13+
RequireScheme,
14+
ResolvedUrl,
15+
UrlValidationRule,
16+
UrlValidator,
17+
)
1018

1119

1220
__all__ = [
1321
'AGENT_CARD_WELL_KNOWN_PATH',
1422
'DEFAULT_RPC_URL',
23+
'BlockPrivateNetworks',
24+
'InvalidUrlError',
25+
'RequireScheme',
26+
'ResolvedUrl',
1527
'TransportProtocol',
28+
'UrlValidationRule',
29+
'UrlValidator',
1630
'proto_utils',
1731
'to_stream_response',
1832
]

src/a2a/utils/url_validator.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Composable URL validation utilities for SSRF protection.
16+
17+
This module provides the foundational URL validation infrastructure
18+
described in issue #1023. It is designed to be composable so that
19+
domain-specific wrappers (e.g. ``AgentCardUrlValidator``,
20+
``PushNotificationUrlValidator``) can be built on top of it.
21+
22+
Key design decisions:
23+
24+
* **Composable rules** — validation logic is split into independent
25+
``UrlValidationRule`` implementations that are run in order. A rule
26+
raises ``InvalidUrlError`` to reject; returning means "continue".
27+
* **Pinned addresses** — ``UrlValidator.validate`` resolves DNS and
28+
returns the resolved addresses so callers can pin the connection to
29+
a specific IP, preventing DNS-rebinding attacks.
30+
* **Configurable strictness** — ``BlockPrivateNetworks`` accepts
31+
``allow_hosts`` and ``allow_cidrs`` so deployments that legitimately
32+
use private networks can opt in.
33+
* **Defense in depth** — ``BlockPrivateNetworks`` also inspects the
34+
host portion of the URL as a literal IP address when ``resolve=False``
35+
is used, so that ``http://127.0.0.1/`` is still rejected even
36+
without DNS resolution.
37+
"""
38+
39+
import asyncio
40+
import contextlib
41+
import ipaddress
42+
import socket
43+
44+
from abc import ABC, abstractmethod
45+
from collections.abc import Callable, Sequence
46+
from dataclasses import dataclass
47+
from urllib.parse import SplitResult, urlsplit
48+
49+
50+
IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
51+
Resolver = Callable[[str, int | None], Sequence[IPAddress | str]]
52+
53+
54+
class InvalidUrlError(ValueError):
55+
"""Raised when URL validation rejects a URL."""
56+
57+
58+
@dataclass(frozen=True)
59+
class ResolvedUrl:
60+
"""A parsed URL and the resolved addresses used for validation.
61+
62+
Callers can use ``addresses`` to pin the outbound connection to a
63+
specific IP, preventing DNS-rebinding attacks.
64+
"""
65+
66+
raw: str
67+
parsed: SplitResult
68+
addresses: tuple[IPAddress, ...]
69+
70+
71+
class UrlValidationRule(ABC):
72+
"""A composable URL validation rule.
73+
74+
Subclass and implement ``check``. Raise ``InvalidUrlError`` to
75+
reject the URL; return normally to allow subsequent rules to run.
76+
"""
77+
78+
@abstractmethod
79+
async def check(self, url: ResolvedUrl) -> None:
80+
"""Raise ``InvalidUrlError`` to reject the URL."""
81+
82+
83+
class RequireScheme(UrlValidationRule):
84+
"""Require a URL scheme to be one of the configured schemes.
85+
86+
Typical usage::
87+
88+
RequireScheme(['https']) # HTTPS only
89+
RequireScheme(['http', 'https']) # HTTP or HTTPS
90+
"""
91+
92+
def __init__(self, allowed_schemes: Sequence[str]) -> None:
93+
if not allowed_schemes:
94+
raise ValueError('allowed_schemes must not be empty.')
95+
self._allowed_schemes = frozenset(
96+
scheme.lower() for scheme in allowed_schemes
97+
)
98+
99+
async def check(self, url: ResolvedUrl) -> None:
100+
"""Reject URLs whose scheme is not configured as allowed."""
101+
scheme = url.parsed.scheme.lower()
102+
if scheme not in self._allowed_schemes:
103+
allowed = ', '.join(sorted(self._allowed_schemes))
104+
raise InvalidUrlError(
105+
f'URL scheme {url.parsed.scheme!r} is not allowed. '
106+
f'Allowed schemes: {allowed}.'
107+
)
108+
109+
110+
class BlockPrivateNetworks(UrlValidationRule):
111+
"""Reject URLs resolving to non-public IP addresses.
112+
113+
Hosts in ``allow_hosts`` and addresses covered by ``allow_cidrs``
114+
are exempt from the non-public address check.
115+
116+
When ``resolve=False`` is configured on the ``UrlValidator``, the
117+
``addresses`` tuple will be empty. In that case this rule still
118+
inspects the host portion of the URL as a literal IP address so
119+
that ``http://127.0.0.1/`` is rejected even without DNS resolution.
120+
"""
121+
122+
def __init__(
123+
self,
124+
*,
125+
allow_hosts: Sequence[str] = (),
126+
allow_cidrs: Sequence[str] = (),
127+
) -> None:
128+
self._allow_hosts = frozenset(
129+
_normalize_host(host) for host in allow_hosts
130+
)
131+
self._allow_networks = tuple(
132+
ipaddress.ip_network(cidr, strict=False) for cidr in allow_cidrs
133+
)
134+
135+
async def check(self, url: ResolvedUrl) -> None:
136+
"""Reject URLs that resolve to non-public addresses."""
137+
host = url.parsed.hostname
138+
if host is not None and _normalize_host(host) in self._allow_hosts:
139+
return
140+
141+
# Use resolved addresses when available; fall back to parsing
142+
# the host as a literal IP for the resolve=False case.
143+
addresses = url.addresses
144+
if not addresses and host is not None:
145+
with contextlib.suppress(ValueError):
146+
addresses = (ipaddress.ip_address(host),)
147+
148+
for address in addresses:
149+
if any(address in network for network in self._allow_networks):
150+
continue
151+
if not address.is_global:
152+
raise InvalidUrlError(
153+
f'URL host {host!r} resolves to non-public address '
154+
f'{address}.'
155+
)
156+
157+
158+
class UrlValidator:
159+
"""Validate URLs by parsing, resolving, then running rules in order.
160+
161+
Example::
162+
163+
validator = UrlValidator(
164+
[
165+
RequireScheme(['https']),
166+
BlockPrivateNetworks(),
167+
]
168+
)
169+
resolved = await validator.validate('https://example.com/agent')
170+
# Use resolved.addresses to pin the connection IP.
171+
"""
172+
173+
def __init__(
174+
self,
175+
rules: Sequence[UrlValidationRule] = (),
176+
*,
177+
resolve: bool = True,
178+
resolver: Resolver | None = None,
179+
) -> None:
180+
self._rules = tuple(rules)
181+
self._resolve = resolve
182+
self._resolver = resolver
183+
184+
async def validate(self, url: str) -> ResolvedUrl:
185+
"""Validate a URL and return the parsed URL plus resolved addresses."""
186+
resolved = await self._build(url)
187+
for rule in self._rules:
188+
await rule.check(resolved)
189+
return resolved
190+
191+
async def _build(self, url: str) -> ResolvedUrl:
192+
try:
193+
parsed = urlsplit(url)
194+
host = parsed.hostname
195+
port = parsed.port
196+
except ValueError as exc:
197+
raise InvalidUrlError(f'Invalid URL {url!r}: {exc}') from exc
198+
199+
addresses: tuple[IPAddress, ...] = ()
200+
if self._resolve:
201+
if host is None:
202+
raise InvalidUrlError(f'URL {url!r} does not include a host.')
203+
addresses = await self._resolve_host(host, port)
204+
205+
return ResolvedUrl(raw=url, parsed=parsed, addresses=addresses)
206+
207+
async def _resolve_host(
208+
self, host: str, port: int | None
209+
) -> tuple[IPAddress, ...]:
210+
# Fast path: host is already a literal IP address.
211+
try:
212+
return (ipaddress.ip_address(host),)
213+
except ValueError:
214+
pass
215+
216+
try:
217+
if self._resolver is not None:
218+
resolved = self._resolver(host, port)
219+
else:
220+
loop = asyncio.get_running_loop()
221+
address_info = await loop.getaddrinfo(
222+
host,
223+
port,
224+
type=socket.SOCK_STREAM,
225+
)
226+
resolved = [info[4][0] for info in address_info]
227+
except OSError as exc:
228+
raise InvalidUrlError(
229+
f'Could not resolve URL host {host!r}: {exc}'
230+
) from exc
231+
232+
# Normalise resolver output: accept both str and IPAddress
233+
# instances (fixes review comment on PR #1114).
234+
addresses = tuple(
235+
dict.fromkeys(
236+
addr
237+
if isinstance(
238+
addr, (ipaddress.IPv4Address, ipaddress.IPv6Address)
239+
)
240+
else ipaddress.ip_address(addr)
241+
for addr in resolved
242+
)
243+
)
244+
if not addresses:
245+
raise InvalidUrlError(f'URL host {host!r} did not resolve.')
246+
return addresses
247+
248+
249+
def _normalize_host(host: str) -> str:
250+
return host.rstrip('.').lower()

0 commit comments

Comments
 (0)