feat: add URL validation infrastructure for SSRF protection - #1117
feat: add URL validation infrastructure for SSRF protection#1117Linux2010 wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a composable URL validation utility (UrlValidator) designed for SSRF protection, along with validation rules like RequireScheme and BlockPrivateNetworks, and comprehensive tests. The review feedback suggests several robust improvements: supporting asynchronous custom resolvers by checking and awaiting coroutines, catching raw ValueError exceptions during IP address parsing to consistently raise InvalidUrlError, and normalizing IPv6 hostnames by stripping square brackets to ensure reliable matching in allow_hosts.
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.
| if self._resolver is not None: | ||
| resolved = self._resolver(host, port) |
There was a problem hiding this comment.
Since UrlValidator is designed to be used in an asynchronous context (with validate being an async method), custom resolvers will often need to perform asynchronous I/O (e.g., using aiodns). Currently, the custom resolver is called synchronously. We should support both synchronous and asynchronous resolvers by checking if the returned result is a coroutine and awaiting it if so.
| if self._resolver is not None: | |
| resolved = self._resolver(host, port) | |
| if self._resolver is not None: | |
| resolved = self._resolver(host, port) | |
| if asyncio.iscoroutine(resolved): | |
| resolved = await resolved |
There was a problem hiding this comment.
Good catch! This has been addressed in commit 175f198 ("fix: address PR #1117 review feedback"). The _resolve_host method now checks inspect.isawaitable(result) and awaits the result if it is a coroutine, supporting both sync and async custom resolvers (e.g. aiodns). A test (test_async_resolver_is_awaited) was also added to verify this behavior.
| addresses = tuple( | ||
| dict.fromkeys( | ||
| addr | ||
| if isinstance( | ||
| addr, (ipaddress.IPv4Address, ipaddress.IPv6Address) | ||
| ) | ||
| else ipaddress.ip_address(addr) | ||
| for addr in resolved | ||
| ) | ||
| ) |
There was a problem hiding this comment.
If a custom resolver returns an invalid IP address string, ipaddress.ip_address will raise a raw ValueError. To adhere to the class's contract that all validation/resolution failures raise InvalidUrlError, we should catch ValueError here and raise InvalidUrlError directly without instantiating it.
| addresses = tuple( | |
| dict.fromkeys( | |
| addr | |
| if isinstance( | |
| addr, (ipaddress.IPv4Address, ipaddress.IPv6Address) | |
| ) | |
| else ipaddress.ip_address(addr) | |
| for addr in resolved | |
| ) | |
| ) | |
| try: | |
| addresses = tuple( | |
| dict.fromkeys( | |
| addr | |
| if isinstance( | |
| addr, (ipaddress.IPv4Address, ipaddress.IPv6Address) | |
| ) | |
| else ipaddress.ip_address(addr) | |
| for addr in resolved | |
| ) | |
| ) | |
| except ValueError: | |
| raise InvalidUrlError |
References
- Raise exception classes directly, without instantiating them (e.g.,
raise MyErrorinstead ofraise MyError()).
There was a problem hiding this comment.
Fixed in commit 175f198. The _resolve_host method now wraps ipaddress.ip_address() in a try/except that catches ValueError and re-raises as InvalidUrlError, ensuring all validation/resolution failures consistently raise InvalidUrlError. Test: test_resolver_returning_invalid_address_raises_invalid_url_error.
| def _normalize_host(host: str) -> str: | ||
| return host.rstrip('.').lower() |
There was a problem hiding this comment.
If a user configures an IPv6 address in allow_hosts using the standard bracketed URL notation (e.g., "[::1]"), it won't match the parsed hostname because urllib.parse.urlsplit strips the square brackets from IPv6 literals. Normalizing the host by stripping surrounding square brackets ensures consistent matching for IPv6 addresses.
| def _normalize_host(host: str) -> str: | |
| return host.rstrip('.').lower() | |
| def _normalize_host(host: str) -> str: | |
| normalized = host.rstrip('.').lower() | |
| if normalized.startswith('[') and normalized.endswith(']'): | |
| return normalized[1:-1] | |
| return normalized |
There was a problem hiding this comment.
Fixed in commit 175f198. The _normalize_host() helper now strips surrounding square brackets from IPv6 literals (e.g. [::1] → ::1), ensuring consistent matching when users configure allow_hosts with bracketed notation. Test: test_block_private_networks_allow_ipv6_with_brackets.
🧪 Code Coverage (vs
|
| Base | PR | Delta | |
|---|---|---|---|
| src/a2a/utils/url_validator.py (new) | — | 96.23% | — |
| Total | 92.97% | 93.01% | 🟢 +0.04% |
Generated by coverage-comment.yml
- 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)
…ct#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
- 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)
7b7b0fb to
175f198
Compare
Summary
Implements composable URL validation rules to protect against SSRF attacks when fetching Agent Cards and processing webhook URLs. Closes #1023.
Changes
New files
src/a2a/utils/url_validator.py— Core implementation (251 lines)tests/utils/test_url_validator.py— 31 test cases, 96% module coverageModified files
src/a2a/utils/__init__.py— Export new public symbolsImplementation Details
Public API
InvalidUrlErrorResolvedUrlUrlValidationRuleRequireSchemeBlockPrivateNetworksallow_hostsandallow_cidrsexemptionsUrlValidatorDesign (per issue #1023)
UrlValidationRuleimplementations run in order; a rule raisesInvalidUrlErrorto reject, returning means "continue"UrlValidator.validateresolves DNS and returns resolved addresses so callers can pin the connection to a specific IP, preventing DNS-rebinding attacksBlockPrivateNetworksacceptsallow_hostsandallow_cidrsso deployments that legitimately use private networks can opt inBlockPrivateNetworksalso inspects the host portion of the URL as a literal IP address whenresolve=Falseis used, sohttp://127.0.0.1/is still rejected even without DNS resolutionReview Feedback Addressed (commit
175f198)_resolve_hostnow checksinspect.isawaitable(result)and awaits if the custom resolver returns a coroutine — supports both sync and async resolvers (e.g. aiodns)ValueErrorfromipaddress.ip_address()on invalid resolver output is now caught and re-raised asInvalidUrlError, ensuring all validation failures use the same exception type_normalize_host()strips surrounding square brackets from IPv6 literals (e.g.[::1]→::1), ensuring consistent matching inallow_hostsTest Plan
ruff checkpassesruff formatpassesty checkpassesSecurity Impact