Skip to content

Commit 175f198

Browse files
committed
fix: address PR a2aproject#1117 review feedback
- Support async resolvers (e.g. aiodns) by detecting and awaiting coroutine return values from custom Resolver callbacks - Convert ip_address ValueError from resolver output to InvalidUrlError for consistent error handling - Strip IPv6 brackets in _normalize_host so allow_hosts=['[::1]'] correctly matches parsed hostname '::1' - Add 3 new test cases covering these fixes (31 total, 96% coverage)
1 parent 1cba834 commit 175f198

2 files changed

Lines changed: 79 additions & 12 deletions

File tree

src/a2a/utils/url_validator.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,22 @@
3838

3939
import asyncio
4040
import contextlib
41+
import inspect
4142
import ipaddress
4243
import socket
4344

4445
from abc import ABC, abstractmethod
45-
from collections.abc import Callable, Sequence
46+
from collections.abc import Awaitable, Callable, Sequence
4647
from dataclasses import dataclass
48+
from typing import cast
4749
from urllib.parse import SplitResult, urlsplit
4850

4951

5052
IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
51-
Resolver = Callable[[str, int | None], Sequence[IPAddress | str]]
53+
Resolver = Callable[
54+
[str, int | None],
55+
Sequence[IPAddress | str] | Awaitable[Sequence[IPAddress | str]],
56+
]
5257

5358

5459
class InvalidUrlError(ValueError):
@@ -215,7 +220,13 @@ async def _resolve_host(
215220

216221
try:
217222
if self._resolver is not None:
218-
resolved = self._resolver(host, port)
223+
result = self._resolver(host, port)
224+
# Support async resolvers (e.g. aiodns) that return
225+
# coroutines / awaitables.
226+
if inspect.isawaitable(result):
227+
resolved = cast('Sequence[IPAddress | str]', await result)
228+
else:
229+
resolved = result
219230
else:
220231
loop = asyncio.get_running_loop()
221232
address_info = await loop.getaddrinfo(
@@ -231,20 +242,30 @@ async def _resolve_host(
231242

232243
# Normalise resolver output: accept both str and IPAddress
233244
# instances (fixes review comment on PR #1114).
234-
addresses = tuple(
235-
dict.fromkeys(
236-
addr
237-
if isinstance(
238-
addr, (ipaddress.IPv4Address, ipaddress.IPv6Address)
245+
try:
246+
addresses = tuple(
247+
dict.fromkeys(
248+
addr
249+
if isinstance(
250+
addr, (ipaddress.IPv4Address, ipaddress.IPv6Address)
251+
)
252+
else ipaddress.ip_address(addr)
253+
for addr in resolved
239254
)
240-
else ipaddress.ip_address(addr)
241-
for addr in resolved
242255
)
243-
)
256+
except ValueError as exc:
257+
raise InvalidUrlError(
258+
f'Resolver returned invalid address for {host!r}: {exc}'
259+
) from exc
244260
if not addresses:
245261
raise InvalidUrlError(f'URL host {host!r} did not resolve.')
246262
return addresses
247263

248264

249265
def _normalize_host(host: str) -> str:
250-
return host.rstrip('.').lower()
266+
"""Normalize a hostname for comparison.
267+
268+
Strips trailing dots, lowercases, and removes surrounding square
269+
brackets from IPv6 literals (e.g. ``[::1]`` → ``::1``).
270+
"""
271+
return host.strip('[]').rstrip('.').lower()

tests/utils/test_url_validator.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,23 @@ def resolver(host: str, port: int | None) -> list[str]:
374374
assert result.addresses == (ipaddress.ip_address('127.0.0.1'),)
375375

376376

377+
@pytest.mark.asyncio
378+
async def test_block_private_networks_allow_ipv6_with_brackets() -> None:
379+
"""allow_hosts strips surrounding brackets from IPv6 literals."""
380+
381+
def resolver(host: str, port: int | None) -> list[str]:
382+
return ['::1']
383+
384+
validator = UrlValidator(
385+
[BlockPrivateNetworks(allow_hosts=['[::1]'])],
386+
resolver=resolver,
387+
)
388+
389+
result = await validator.validate('http://[::1]/callback')
390+
391+
assert result.addresses == (ipaddress.ip_address('::1'),)
392+
393+
377394
# ---------------------------------------------------------------------------
378395
# Resolver returning IPAddress objects (PR #1114 review fix)
379396
# ---------------------------------------------------------------------------
@@ -393,6 +410,35 @@ def resolver(host: str, port: int | None) -> list[ipaddress.IPv4Address]:
393410
assert result.addresses == (ipaddress.ip_address('93.184.216.34'),)
394411

395412

413+
@pytest.mark.asyncio
414+
async def test_async_resolver_is_awaited() -> None:
415+
"""Custom resolvers may return awaitables (e.g. aiodns)."""
416+
417+
async def resolver(host: str, port: int | None) -> list[str]:
418+
return ['93.184.216.34']
419+
420+
validator = UrlValidator(resolver=resolver)
421+
422+
result = await validator.validate('http://example.com/')
423+
424+
assert result.addresses == (ipaddress.ip_address('93.184.216.34'),)
425+
426+
427+
@pytest.mark.asyncio
428+
async def test_resolver_returning_invalid_address_raises_invalid_url_error() -> (
429+
None
430+
):
431+
"""Resolver returning non-IP strings raises InvalidUrlError."""
432+
433+
def resolver(host: str, port: int | None) -> list[str]:
434+
return ['not-an-ip-address']
435+
436+
validator = UrlValidator(resolver=resolver)
437+
438+
with pytest.raises(InvalidUrlError, match='invalid address'):
439+
await validator.validate('http://example.com/')
440+
441+
396442
# ---------------------------------------------------------------------------
397443
# Rule composition
398444
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)