Skip to content

fix(aiohttp): block private/reserved IPs in aiohttp handler to close SSRF gap (CWE-918) - #30284

Open
DrishnaTrivedi wants to merge 15 commits into
BerriAI:litellm_internal_stagingfrom
DrishnaTrivedi:fix/aiohttp-ssrf-cwe918
Open

fix(aiohttp): block private/reserved IPs in aiohttp handler to close SSRF gap (CWE-918)#30284
DrishnaTrivedi wants to merge 15 commits into
BerriAI:litellm_internal_stagingfrom
DrishnaTrivedi:fix/aiohttp-ssrf-cwe918

Conversation

@DrishnaTrivedi

Copy link
Copy Markdown
Contributor

Relevant issues

Fixes #26264

Linear ticket

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai

Type

🐛 Bug Fix / Security

Changes

#26264 added SSRF protection to the httpx handler but left aiohttp_handler.py unguarded.
Models configured with the aiohttp_openai provider and a user-controlled api_base could make
the proxy reach internal network resources (private RFC 1918 ranges, loopback, AWS metadata
endpoint, etc.).

aiohttp_handler.py:

  • Added _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
  • Added _SSRFGuardResolver — custom aiohttp resolver that validates all DNS answers at
    TCP-connect time, covering hostname-based URLs and redirect hops; prevents A-record rotation
    attacks
  • Added _SSRFGuardTransport — sync-path httpx transport that resolves, validates, and pins the
    IP at connect time, closing the DNS-rebinding TOCTOU window
  • Added _on_ssrf_request_start TraceConfig hook — catches IP-literal redirect targets that
    bypass the resolver since aiohttp skips DNS for those
  • Replaced _get_httpx_client() with _get_ssrf_safe_sync_client() on all three sync call
    sites to ensure the guarded transport is always used

init.py:

  • Added litellm.allow_requests_to_internal_ips flag (default False) for self-hosted
    deployments where api_base intentionally points at an internal address (e.g. local Ollama,
    vLLM)

Tests:

  • Added tests/test_litellm/llms/test_aiohttp_ssrf_protection.py with 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
  • Updated tests/test_litellm/llms/custom_httpx/test_aiohttp_handler.py with additional handler
    lifecycle coverage

Screenshots / Proof of Fix

image

DrishnaTrivedi and others added 13 commits June 12, 2026 14:20
…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>
@DrishnaTrivedi
DrishnaTrivedi changed the base branch from litellm_internal_staging to litellm_oss_staging_120626 June 12, 2026 09:27
@codecov

codecov Bot commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.70992% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/llms/custom_httpx/aiohttp_handler.py 97.67% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@DrishnaTrivedi
DrishnaTrivedi changed the base branch from litellm_oss_staging_120626 to litellm_internal_staging June 12, 2026 09:31
@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR closes the SSRF gap left in aiohttp_handler.py after issue #26264 protected the httpx path. It adds four layered guards — _SSRFGuardResolver, _SSRFGuardTransport, _assert_not_private_ip_literal, and a TraceConfig hook — plus an opt-out flag for on-prem deployments.

  • aiohttp_handler.py replaces the unguarded _get_httpx_client() with _get_ssrf_safe_sync_client() on all three sync call sites and attaches the resolver and trace config to all default-path ClientSession creations.
  • http_handler.py gains a transport= parameter on HTTPHandler.__init__ so _SSRFGuardTransport can be injected without subclassing.
  • 73 new mock-only tests cover all guards, opt-out, and edge cases; existing session-creation tests are updated for the new TCPConnector instantiation.

Confidence Score: 5/5

Safe 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 _create_client_session_with_transport in aiohttp_handler.py silently skip the resolver guard; a comment explaining the intentional omission would help future maintainers.

Important Files Changed

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

Comment thread litellm/llms/custom_httpx/aiohttp_handler.py Outdated
Comment thread litellm/llms/custom_httpx/aiohttp_handler.py
Comment thread litellm/llms/custom_httpx/aiohttp_handler.py Outdated
# 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@veria-ai

veria-ai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

PR overview

This 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 litellm/llms/custom_httpx/aiohttp_handler.py.

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 api_base or a redirect response could still cause outbound requests to internal or reserved addresses. One issue has already been addressed, but the remaining bypasses keep the PR at a high security risk until redirect handling and supplied-session behavior are tightened.

Open issues (2)

Fixed/addressed: 1 · PR risk: 8/10

@DrishnaTrivedi

Copy link
Copy Markdown
Contributor Author

@greptileai

@DrishnaTrivedi

Copy link
Copy Markdown
Contributor Author

@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()],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants