fix(aiohttp/ssrf): use is_global for complete IP blocking and remove async preflight

Replace the manual _BLOCKED_NETWORKS list with addr.is_global — this automatically
covers all IANA special-use ranges including RFC 2544 benchmarking (198.18.0.0/15),
class E reserved (240.0.0.0/4), and RFC 5737 documentation (192.0.2.0/24) that
were previously missing. CGNAT (100.64.0.0/10) is still explicitly checked for
Python < 3.11 compat where is_global incorrectly returns True for that range.

Remove the blocking socket.getaddrinfo() preflight from _make_common_async_call —
it stalled the event loop under concurrent load. The _SSRFGuardResolver (attached
to the TCPConnector) already enforces the same check non-blocking at TCP-connect
time, including redirect targets. The sync path retains _assert_not_private_url
since httpx has no equivalent connection-time resolver hook.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Drishna Trivedi 2026-05-20 15:09:32 +05:30
parent 6c22ca0d6e
commit 90ec5dcf67
2 changed files with 48 additions and 19 deletions

View file

@ -36,27 +36,25 @@ else:
DEFAULT_TIMEOUT = 600
_BLOCKED_NETWORKS = [
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("100.64.0.0/10"), # CGNAT
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"), # Link-local / AWS IMDS
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("::/128"), # IPv6 unspecified / wildcard
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"), # IPv6 link-local
]
# CGNAT (100.64.0.0/10) is misclassified as global on Python < 3.11
_CGNAT = ipaddress.ip_network("100.64.0.0/10")
def _is_blocked_address(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
"""Return True if addr falls in any blocked network."""
"""Return True if addr is not globally routable (private, loopback, reserved, etc.).
Uses Python's built-in is_global to cover all IANA special-use ranges —
including RFC 5737 documentation (192.0.2.0/24), RFC 2544 benchmarking
(198.18.0.0/15), and class E (240.0.0.0/4) — without maintaining a manual list.
CGNAT (100.64.0.0/10) is explicitly checked for Python < 3.11 compat.
"""
# Unwrap IPv4-mapped IPv6 (::ffff:10.0.0.1 → 10.0.0.1)
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
addr = addr.ipv4_mapped
return any(addr in net for net in _BLOCKED_NETWORKS)
# Python < 3.11 incorrectly marks CGNAT as global
if isinstance(addr, ipaddress.IPv4Address) and addr in _CGNAT:
return True
return not addr.is_global or addr.is_multicast
def _assert_not_private_url(url: str) -> None:
@ -292,7 +290,11 @@ class BaseLLMAIOHTTPHandler:
dynamic_client_session=async_client_session
)
_assert_not_private_url(api_base)
# SSRF validation on the async path is handled at TCP-connect time by
# _SSRFGuardResolver (attached to the TCPConnector). A synchronous
# socket.getaddrinfo() preflight would block the event loop, so it is
# intentionally omitted here. The sync path retains _assert_not_private_url
# because httpx has no equivalent connection-time resolver hook.
for i in range(max(max_retry_on_unprocessable_entity_error, 1)):
try:

View file

@ -32,6 +32,15 @@ class TestBlockedAddress:
def test_0_0_0_0_blocked(self):
assert _is_blocked_address(ipaddress.ip_address("0.0.0.0"))
def test_benchmarking_range_blocked(self):
assert _is_blocked_address(ipaddress.ip_address("198.18.0.1"))
def test_class_e_reserved_blocked(self):
assert _is_blocked_address(ipaddress.ip_address("240.0.0.1"))
def test_documentation_range_blocked(self):
assert _is_blocked_address(ipaddress.ip_address("192.0.2.1"))
class TestAiohttpSSRFProtection:
def test_aws_metadata_endpoint_blocked(self):
@ -150,10 +159,13 @@ class TestAllowInternalIpsOptOut:
class TestSSRFGuardOnRequestMethods:
"""Verify _assert_not_private_url is actually called in the request paths."""
"""Verify SSRF protection is enforced on both sync and async request paths."""
@pytest.mark.asyncio
async def test_make_common_async_call_blocks_private_ip(self):
async def test_make_common_async_call_does_not_block_event_loop(self):
"""Async path delegates SSRF to _SSRFGuardResolver (connection-time),
not a sync preflight — so a mock session with a private api_base must
NOT raise at preflight; it proceeds until the actual TCP connect."""
from unittest.mock import AsyncMock, Mock
from litellm.llms.custom_httpx.aiohttp_handler import BaseLLMAIOHTTPHandler
@ -161,9 +173,18 @@ class TestSSRFGuardOnRequestMethods:
handler = BaseLLMAIOHTTPHandler()
mock_config = Mock()
mock_config.max_retry_on_unprocessable_entity_error = 1
# Mock session that raises aiohttp.ClientError on connect (simulating resolver block)
mock_session = AsyncMock()
mock_response = AsyncMock()
mock_response.ok = True
mock_session.post.return_value.__aenter__ = AsyncMock(
return_value=mock_response
)
mock_session.post.return_value.__aexit__ = AsyncMock(return_value=False)
with pytest.raises(ValueError, match="private/reserved"):
# Should NOT raise ValueError at preflight — sync DNS check was removed
# to avoid blocking the event loop. Protection is via _SSRFGuardResolver.
try:
await handler._make_common_async_call(
async_client_session=mock_session,
provider_config=mock_config,
@ -173,8 +194,14 @@ class TestSSRFGuardOnRequestMethods:
timeout=30,
litellm_params={},
)
except ValueError as e:
pytest.fail(f"Async path must not do a blocking preflight DNS check: {e}")
except Exception:
pass # Other errors (e.g. from mock) are fine
def test_make_common_sync_call_blocks_private_ip(self):
"""Sync path still runs _assert_not_private_url preflight since httpx
has no connection-time resolver hook."""
from unittest.mock import Mock
from litellm.llms.custom_httpx.aiohttp_handler import BaseLLMAIOHTTPHandler