Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions litellm/litellm_core_utils/url_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,3 +426,38 @@ async def async_safe_get(client: Any, url: str, **kwargs: Any) -> Any:
# relative Location headers keep the original hostname.
url = _extract_redirect_url(response, url)
raise SSRFError("Too many redirects")


async def async_safe_request(client: Any, method: str, url: str, **kwargs: Any) -> Any:
"""Method-generic version of ``async_safe_get`` with SSRF protection.

``async_safe_get`` only covers GET; callers that need POST/PUT/DELETE/PATCH
(e.g. the custom-code guardrail HTTP primitive) must not fall back to a raw
client call that skips validation. This validates the initial URL and every
redirect hop, connecting to the validated IP with the original Host header
and never letting the client follow redirects on its own.

``client`` must expose ``client.request(method, url, ...)`` (an
``httpx.AsyncClient``); the SSRF-safe redirect loop needs per-hop control,
which the higher-level ``AsyncHTTPHandler`` non-GET methods don't offer.
"""
if not getattr(litellm, "user_url_validation", True):
kwargs.setdefault("follow_redirects", True)
return await client.request(method, url, **kwargs)
kwargs.pop("follow_redirects", None)
caller_headers = kwargs.pop("headers", {}) or {}
for _ in range(_MAX_REDIRECTS):
validated_url, original_host = validate_url(url)
response = await client.request(
method,
validated_url,
headers={**caller_headers, "Host": original_host},
follow_redirects=False,
**kwargs,
)
if not response.is_redirect:
return response
# Resolve the next hop against the ORIGINAL (pre-rewrite) URL so
# relative Location headers keep the original hostname.
url = _extract_redirect_url(response, url)
raise SSRFError("Too many redirects")
44 changes: 15 additions & 29 deletions litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import httpx

from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_request
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.types.llms.custom_http import httpxSpecialProvider

Expand Down Expand Up @@ -468,16 +469,26 @@ async def http_request(
params={"timeout": httpx.Timeout(timeout=timeout, connect=5.0)},
)

json_body, data_body = _prepare_http_body(body)

try:
response = await _execute_http_request(client, method, url, headers, body, timeout)
response = await async_safe_request(
client.client,
method,
url,
headers=headers,
json=json_body,
data=data_body,
timeout=timeout,
)
return _http_success_response(response)

except SSRFError as e:
verbose_proxy_logger.warning(f"Custom code http_request blocked by SSRF protection: {e}")
return _http_error_response(f"Blocked by SSRF protection: {str(e)}")
except httpx.TimeoutException as e:
verbose_proxy_logger.warning(f"Custom code http_request timeout: {e}")
return _http_error_response(f"Request timeout after {timeout}s")
except httpx.HTTPStatusError as e:
# Return the response even for non-2xx status codes
return _http_success_response(e.response)
except httpx.RequestError as e:
verbose_proxy_logger.warning(f"Custom code http_request error: {e}")
return _http_error_response(f"Request failed: {str(e)}")
Expand All @@ -486,31 +497,6 @@ async def http_request(
return _http_error_response(f"Unexpected error: {str(e)}")


async def _execute_http_request(
client: Any,
method: str,
url: str,
headers: Optional[Dict[str, str]],
body: Optional[Any],
timeout: float,
) -> httpx.Response:
"""Execute the HTTP request using the appropriate client method."""
json_body, data_body = _prepare_http_body(body)

if method == "GET":
return await client.get(url=url, headers=headers)
elif method == "POST":
return await client.post(url=url, headers=headers, json=json_body, data=data_body, timeout=timeout)
elif method == "PUT":
return await client.put(url=url, headers=headers, json=json_body, data=data_body, timeout=timeout)
elif method == "DELETE":
return await client.delete(url=url, headers=headers, json=json_body, data=data_body, timeout=timeout)
elif method == "PATCH":
return await client.patch(url=url, headers=headers, json=json_body, data=data_body, timeout=timeout)
else:
raise ValueError(f"Unsupported HTTP method: {method}")


async def http_get(
url: str,
headers: Optional[Dict[str, str]] = None,
Expand Down
92 changes: 92 additions & 0 deletions tests/test_litellm/litellm_core_utils/test_url_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -535,3 +535,95 @@ def test_assert_same_origin_error_message_does_not_leak_hostnames():
detail = str(exc.value)
assert "attacker.example.com" not in detail
assert "api.internal-corp.example" not in detail


class TestAsyncSafeRequest:
"""async_safe_request is the method-generic sibling of async_safe_get and
must apply the same SSRF protections for POST/PUT/DELETE/PATCH."""

@pytest.mark.asyncio
async def test_blocks_private_ip_for_post(self, monkeypatch):
def fake(host, port, *a, **kw):
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", port))]

monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake)

class FakeClient:
async def request(self, *a, **kw):
raise AssertionError("request must not be issued for a blocked IP")

with pytest.raises(SSRFError):
await url_utils.async_safe_request(
FakeClient(), "POST", "http://example.com/x", json={"a": 1}
)

@pytest.mark.asyncio
async def test_blocks_metadata_ip_for_post(self, monkeypatch):
def fake(host, port, *a, **kw):
return [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", port))
]

monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake)

class FakeClient:
async def request(self, *a, **kw):
raise AssertionError("request must not reach cloud metadata")

with pytest.raises(SSRFError):
await url_utils.async_safe_request(
FakeClient(), "GET", "http://metadata.internal/latest"
)

@pytest.mark.asyncio
async def test_validates_each_redirect_hop(self, monkeypatch):
"""A public host that redirects to a private target must be blocked at the
redirect hop before a second request is issued."""

def fake(host, port, *a, **kw):
ip = "93.184.216.34" if host == "public.example" else "127.0.0.1"
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port))]

monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake)

class FakeResponse:
def __init__(self, status, location=None):
self.status_code = status
self.headers = {"location": location} if location else {}
self.is_redirect = 300 <= status < 400

hops = []

class FakeClient:
async def request(self, method, url, headers=None, **kw):
hops.append({"method": method, "host": (headers or {}).get("Host")})
return FakeResponse(302, "http://private.example/internal")

with pytest.raises(SSRFError):
await url_utils.async_safe_request(
FakeClient(), "DELETE", "http://public.example/start"
)
assert len(hops) == 1
assert hops[0]["method"] == "DELETE"
assert hops[0]["host"] == "public.example"

@pytest.mark.asyncio
async def test_master_switch_disabled_bypasses_validation(self, monkeypatch):
monkeypatch.setattr(litellm, "user_url_validation", False)
calls = []

class FakeClient:
async def request(self, method, url, **kwargs):
calls.append((method, url, kwargs))

class R:
is_redirect = False

return R()

await url_utils.async_safe_request(
FakeClient(), "POST", "http://127.0.0.1/internal", json={"a": 1}
)
assert calls and calls[0][0] == "POST"
assert calls[0][1] == "http://127.0.0.1/internal"
assert calls[0][2].get("follow_redirects") is True
134 changes: 134 additions & 0 deletions tests/test_litellm/proxy/guardrails/test_custom_code_primitives.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import socket

import pytest

from litellm.litellm_core_utils import url_utils
from litellm.proxy.guardrails.guardrail_hooks.custom_code import primitives


def _mock_dns(monkeypatch, ip: str):
def fake_getaddrinfo(host, port, *args, **kwargs):
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port or 80))]

monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake_getaddrinfo)


class _FakeResponse:
def __init__(self, status_code=200, json_body=None, location=None):
self.status_code = status_code
self._json = json_body if json_body is not None else {"ok": True}
self.headers = {"location": location} if location else {}
self.is_redirect = 300 <= status_code < 400

def json(self):
return self._json

@property
def text(self):
return str(self._json)


class _FakeRawClient:
def __init__(self, response=None):
self.response = response or _FakeResponse()
self.calls = []

async def request(self, method, url, headers=None, **kwargs):
self.calls.append({"method": method, "url": url, "headers": headers or {}})
return self.response


class _FakeHandler:
def __init__(self, raw_client):
self.client = raw_client


@pytest.mark.asyncio
async def test_http_request_blocks_cloud_metadata(monkeypatch):
"""Regression for the guardrail SSRF: http_request() must run the URL
through validate_url() so the AWS/GCP/Azure metadata IP is rejected and
no outbound request is ever issued."""
_mock_dns(monkeypatch, "169.254.169.254")

raw_client = _FakeRawClient()
monkeypatch.setattr(
primitives, "get_async_httpx_client", lambda **kw: _FakeHandler(raw_client)
)

result = await primitives.http_request(
"http://169.254.169.254/latest/meta-data/iam/security-credentials/"
)

assert result["success"] is False
assert "SSRF" in result["error"]
assert raw_client.calls == []


@pytest.mark.asyncio
async def test_http_request_blocks_private_ip(monkeypatch):
_mock_dns(monkeypatch, "127.0.0.1")

raw_client = _FakeRawClient()
monkeypatch.setattr(
primitives, "get_async_httpx_client", lambda **kw: _FakeHandler(raw_client)
)

result = await primitives.http_request("http://localhost:6379/", method="POST")

assert result["success"] is False
assert "SSRF" in result["error"]
assert raw_client.calls == []


@pytest.mark.asyncio
async def test_http_request_does_not_follow_redirect_to_private(monkeypatch):
"""A public host that 302-redirects to a private target must be blocked at
the redirect hop rather than followed into the internal network."""

def fake_getaddrinfo(host, port, *args, **kwargs):
ip = "93.184.216.34" if host == "public.example" else "127.0.0.1"
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port or 80))]

monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake_getaddrinfo)

raw_client = _FakeRawClient(
response=_FakeResponse(status_code=302, location="http://internal.example/")
)
monkeypatch.setattr(
primitives, "get_async_httpx_client", lambda **kw: _FakeHandler(raw_client)
)

result = await primitives.http_request("http://public.example/start")

assert result["success"] is False
assert "SSRF" in result["error"]
assert len(raw_client.calls) == 1


@pytest.mark.asyncio
async def test_http_request_allows_public_host(monkeypatch):
"""The happy path still works: a public host resolves, the validated request
is issued to the resolved IP with the original Host header preserved."""
_mock_dns(monkeypatch, "93.184.216.34")

raw_client = _FakeRawClient(
response=_FakeResponse(status_code=200, json_body={"verdict": "clean"})
)
monkeypatch.setattr(
primitives, "get_async_httpx_client", lambda **kw: _FakeHandler(raw_client)
)

result = await primitives.http_request(
"http://api.example.com/moderate",
method="POST",
body={"text": "hello"},
)

assert result["success"] is True
assert result["status_code"] == 200
assert result["body"] == {"verdict": "clean"}
assert len(raw_client.calls) == 1
call = raw_client.calls[0]
assert call["method"] == "POST"
assert "93.184.216.34" in call["url"]
assert call["headers"].get("Host") == "api.example.com"
Loading