fix(aiohttp): block private/reserved IPs in aiohttp handler to close SSRF gap (CWE-918) - #30284
Conversation
…p from BerriAI#26264 (CWE-918) aiohttp_handler.py was not covered by the SSRF protection in PR BerriAI#26264. User-controlled api_base was passed directly to session.post() without IP validation. Protection added: - Blocks RFC-1918, loopback, link-local (169.254/16, fe80::/10), CGNAT, 0.0.0.0/8, IPv6 ULA/loopback - Unwraps IPv4-mapped IPv6 (::ffff:x.x.x.x) before network check - Validates ALL getaddrinfo answers to prevent A-record rotation bypass - _SSRFGuardResolver (AbstractResolver) validates IPs at TCP-connection time inside aiohttp's own connection loop — covers redirect targets and eliminates DNS-rebinding TOCTOU - Default ClientSession creation uses TCPConnector(resolver=_SSRFGuardResolver()) - Sync path (_make_common_sync_call via httpx) guarded with preflight check Tests: - 18 new tests in tests/test_litellm/llms/test_aiohttp_ssrf_protection.py - Updated 4 existing tests in test_aiohttp_handler.py to mock TCPConnector Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Unparseable IP in getaddrinfo answer (lines 79-80, 111-112) - DNS failure in _SSRFGuardResolver.resolve() (lines 105-106) - _SSRFGuardResolver.close() noop (line 131) - _make_common_async_call and _make_common_sync_call block private api_base (lines 297, 343) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…FGuardResolver - Replace asyncio.get_event_loop() with get_running_loop() inside _SSRFGuardResolver.resolve() — get_event_loop() is deprecated in Python 3.10+ and emits DeprecationWarning when called from a coroutine - Propagate socket.gaierror instead of silently returning [] so aiohttp wraps it in ClientConnectorError as callers expect - Update TestSSRFGuardResolver to use asyncio.run() and get_running_loop() in async helpers, and rename the DNS-failure test to reflect the new raise-on-failure contract Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… asyncio deprecation Adds litellm.allow_requests_to_internal_ips flag (default False) so self-hosted / on-prem deployments pointing api_base at an internal address (e.g. Ollama, vLLM) can opt out of SSRF protection rather than being unconditionally blocked. Both _assert_not_private_url and _SSRFGuardResolver respect the flag. Also replaces the deprecated asyncio.get_event_loop() call in BaseLLMAIOHTTPHandler.__del__ with asyncio.get_running_loop(), falling back to a fresh event loop — eliminating the DeprecationWarning raised in Python 3.10+. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ard bypass Adds ::/128 to _BLOCKED_NETWORKS — the IPv6 unspecified/wildcard address was missing, allowing an attacker to bypass SSRF protection by resolving a hostname to :: which binds to all local interfaces. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…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>
…olver aiohttp's TCPConnector skips _SSRFGuardResolver when the URL host is already an IP address (no DNS lookup performed), allowing direct access to private addresses like 169.254.169.254 or 127.0.0.1. Add _assert_not_private_ip_literal, a fast non-blocking preflight that catches this bypass on the async path without requiring socket I/O. Hostname-based URLs continue to be protected by _SSRFGuardResolver at TCP-connect time. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…uardTransport The preflight _assert_not_private_url call resolved the hostname with socket.getaddrinfo but httpx/httpcore performed its own DNS resolution at connect time, leaving a TOCTOU window where an attacker controlling DNS could serve a public IP during the check and switch to a private IP (e.g. 169.254.169.254) before the actual connection. Fixes this by introducing _SSRFGuardTransport(httpx.HTTPTransport), which mirrors _SSRFGuardResolver on the async path: it resolves the hostname once, validates every returned IP, then rewrites the request URL to the pinned IP so httpcore connects directly without a second DNS lookup — closing the window entirely. The original hostname is preserved in the Host header and the sni_hostname extension so TLS certificate validation and virtual-host routing are unaffected. HTTPHandler gains an optional transport= parameter so the guard transport can be injected while preserving all existing SSL/cert/timeout/header config. All three sync client creation sites in aiohttp_handler.py now use _get_ssrf_safe_sync_client(); _assert_not_private_url remains as defence-in-depth for externally-supplied clients. Tests added to prove: - _SSRFGuardTransport blocks private IPs including all DNS answers - IP pinning: forwarded request carries the resolved IP, not the hostname - Host header and sni_hostname preserved for TLS - _make_common_sync_call succeeds for legitimate (public-IP) URLs, confirming the sync code path still works after the change Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously, when the URL host was already an IP address (e.g. http://10.0.0.1/api), ipaddress.ip_address(host) succeeded and the except-ValueError branch — which contains all SSRF validation — was skipped entirely. A caller using _get_ssrf_safe_sync_client() directly (bypassing _make_common_sync_call's preflight) would therefore pass private IP-literal addresses to the network unchecked. Fix: restructure the try/except so that a successful ip_address() parse immediately calls _is_blocked_address() and raises on private/reserved addresses. Only a ValueError from ip_address() (meaning the host is a hostname, not an IP literal) falls through to the DNS-resolve-and-pin path. No DNS lookup is needed for IP literals — the check is instant. Add five new _SSRFGuardTransport tests covering: - 10.x.x.x IP-literal blocked (no DNS called) - 169.254.169.254 (AWS metadata) IP-literal blocked - ::1 IPv6 loopback IP-literal blocked - allow_requests_to_internal_ips=True bypasses IP-literal check - end-to-end via _get_ssrf_safe_sync_client with IP-literal URL Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
aiohttp's _SSRFGuardResolver runs at DNS time but is bypassed when the server returns a Location header that is already a bare IP literal (no DNS resolution needed, so the resolver never fires). Add _on_ssrf_request_start (aiohttp TraceConfig on_request_start hook) and _make_ssrf_trace_config() to close this gap. The hook fires for every aiohttp request, including redirect hops. It calls _assert_not_private_ip_literal on each URL, blocking private IP-literal redirect targets before aiohttp makes the connection. Hostname-based redirect targets continue to be validated by _SSRFGuardResolver at TCP-connect time. Also adds test_del_without_running_loop_uses_new_event_loop to cover the __del__ asyncio.new_event_loop() fallback branch (the RuntimeError path that was the 1 missing line in Codecov patch coverage). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nfig Two P1 Greptile concerns on the previous commit: 1. _get_ssrf_safe_sync_client() created a new HTTPHandler on every call, dropping the in_memory_llm_clients_cache that _get_httpx_client() used. Under sustained load every sync request incurred a fresh TCP+TLS handshake. Fix: apply the same cache-lookup/store pattern with _DEFAULT_TTL_FOR_HTTPX_CLIENTS. 2. _SSRFGuardTransport() was constructed with httpx defaults (verify=True, no cert). When httpx.Client receives a custom transport= object, it ignores its own verify= and cert= arguments — so litellm.ssl_verify and SSL_CERTIFICATE were silently dropped, breaking self-signed-cert and mTLS deployments. Fix: resolve SSL config via get_ssl_configuration(None) and forward both verify and cert to _SSRFGuardTransport.__init__. Also adds import os (was missing), _DEFAULT_TTL_FOR_HTTPX_CLIENTS from litellm.constants, and get_ssl_configuration from http_handler. Tests added in TestSSRFSafeClientCachingAndSSL: - test_client_is_cached_across_calls - test_ssl_config_is_forwarded_to_transport - test_ssl_certificate_is_forwarded_to_transport Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR closes the SSRF gap left in
Confidence Score: 5/5Safe to merge; all four SSRF guard layers are correctly wired on the default request path, and the opt-out flag checks the litellm global at call-time. No functional regressions were found. The default aiohttp session creation, all three sync call sites, and the async IP-literal pre-check are fully guarded. The noted observations are minor edge cases that do not affect the primary threat model. The transport- and connector-provided branches of
|
| Filename | Overview |
|---|---|
| litellm/llms/custom_httpx/aiohttp_handler.py | Adds four SSRF guard layers. Default session creation is fully protected; transport- and connector-provided paths are unguarded. Empty getaddrinfo results leave a narrow TOCTOU window on the sync transport path. |
| litellm/init.py | Adds allow_requests_to_internal_ips flag (default False); checked at runtime in all guard functions. |
| litellm/llms/custom_httpx/http_handler.py | Adds optional transport= parameter to HTTPHandler.__init__; minimal, focused change. |
| tests/test_litellm/llms/test_aiohttp_ssrf_protection.py | 73 new mock-only tests covering all guard layers, opt-out flag, and edge cases. |
| tests/test_litellm/llms/custom_httpx/test_aiohttp_handler.py | Existing tests updated to mock aiohttp.TCPConnector; new tests cover __del__ fallback and transport-creation failure. |
Reviews (3): Last reviewed commit: "test(ssrf): add missing coverage for no-..." | Re-trigger Greptile
| # here with a fast, non-blocking parse (no socket I/O). Hostname-based URLs | ||
| # are handled by _SSRFGuardResolver at TCP-connect time, which also covers | ||
| # redirect targets, eliminating the DNS-rebinding TOCTOU window. | ||
| _assert_not_private_ip_literal(api_base) |
There was a problem hiding this comment.
Medium: Caller-supplied sessions bypass hostname and redirect checks
When async_client_session is supplied, _get_async_client_session() returns it as-is, so this IP-literal preflight is the only SSRF guard on that path. An attacker who controls api_base can use a hostname that resolves to an internal IP, or a public URL that redirects to an internal hostname/IP, and the unguarded session will make the request; either reject unguarded sessions while SSRF protection is enabled or require the same resolver/trace configuration on supplied sessions.
PR overviewThis PR updates the aiohttp custom HTTP client handler to validate outbound request targets and block private or reserved IP destinations. The touched code centers on session creation, request tracing, and redirect handling in There are still two open gaps in the current protection: caller-supplied aiohttp sessions can bypass the configured hostname/redirect checks, and redirect targets using IP literals are not inspected before aiohttp follows them. As a result, an attacker who controls Open issues (2)
Fixed/addressed: 1 · PR risk: 8/10 |
…ling in SSRF guards
…and transport init failure
|
@greptileai re-review latest commit - 8e4d8a9 |
| # redirect targets that bypass the resolver (no DNS lookup needed). | ||
| session = aiohttp.ClientSession( | ||
| connector=aiohttp.TCPConnector(resolver=_SSRFGuardResolver()), | ||
| trace_configs=[_make_ssrf_trace_config()], |
There was a problem hiding this comment.
High: IP-literal redirect bypass
on_request_start only runs for the initial aiohttp request, not for each redirect hop. An attacker who controls the public api_base response can return Location: http://169.254.169.254/...; aiohttp follows redirects by default, skips the custom resolver for IP literals, and this trace config does not inspect the redirect target. Add an on_request_redirect hook that parses and blocks the Location target before aiohttp continues, or move the IP-literal check into a connector path that runs for every connection.
Relevant issues
Fixes #26264
Linear ticket
Pre-Submission checklist
make test-unit@greptileaiType
🐛 Bug Fix / Security
Changes
#26264 added SSRF protection to the httpx handler but left
aiohttp_handler.pyunguarded.Models configured with the
aiohttp_openaiprovider and a user-controlledapi_basecould makethe proxy reach internal network resources (private RFC 1918 ranges, loopback, AWS metadata
endpoint, etc.).
aiohttp_handler.py:
_assert_not_private_ip_literal— fast pre-call check that blocks bare IP-literal URLs(e.g.
http://192.168.1.1/v1) before any network I/O_SSRFGuardResolver— custom aiohttp resolver that validates all DNS answers atTCP-connect time, covering hostname-based URLs and redirect hops; prevents A-record rotation
attacks
_SSRFGuardTransport— sync-path httpx transport that resolves, validates, and pins theIP at connect time, closing the DNS-rebinding TOCTOU window
_on_ssrf_request_startTraceConfig hook — catches IP-literal redirect targets thatbypass the resolver since aiohttp skips DNS for those
_get_httpx_client()with_get_ssrf_safe_sync_client()on all three sync callsites to ensure the guarded transport is always used
init.py:
litellm.allow_requests_to_internal_ipsflag (defaultFalse) for self-hosteddeployments where
api_baseintentionally points at an internal address (e.g. local Ollama,vLLM)
Tests:
tests/test_litellm/llms/test_aiohttp_ssrf_protection.pywith 73 test cases covering:private IPv4/IPv6 blocking, AWS metadata endpoint, CGNAT, loopback, IP-literal redirects,
DNS-rebinding TOCTOU, opt-out flag, and all resolver/transport edge cases
tests/test_litellm/llms/custom_httpx/test_aiohttp_handler.pywith additional handlerlifecycle coverage
Screenshots / Proof of Fix