mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-25 01:02:15 +00:00
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:
parent
6c22ca0d6e
commit
90ec5dcf67
2 changed files with 48 additions and 19 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue