diff --git a/litellm/llms/custom_httpx/aiohttp_handler.py b/litellm/llms/custom_httpx/aiohttp_handler.py index a7bbbf84503..fd3fcf798fb 100644 --- a/litellm/llms/custom_httpx/aiohttp_handler.py +++ b/litellm/llms/custom_httpx/aiohttp_handler.py @@ -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: diff --git a/tests/test_litellm/llms/test_aiohttp_ssrf_protection.py b/tests/test_litellm/llms/test_aiohttp_ssrf_protection.py index c9ef2aa213a..b576ba0175f 100644 --- a/tests/test_litellm/llms/test_aiohttp_ssrf_protection.py +++ b/tests/test_litellm/llms/test_aiohttp_ssrf_protection.py @@ -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