mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
fix(guardrails): route custom-code http_request() through SSRF validation
This commit is contained in:
parent
109193f26a
commit
fdae54a896
4 changed files with 286 additions and 29 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from urllib.parse import urlparse
|
|||
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
|
||||
|
||||
|
|
@ -468,16 +469,30 @@ 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)
|
||||
# async_safe_request applies full SSRF protection (DNS resolution,
|
||||
# private/cloud-metadata IP blocklist, anti-rebinding, per-hop redirect
|
||||
# validation) and never follows redirects on its own, so it needs the
|
||||
# raw httpx client rather than the AsyncHTTPHandler wrapper.
|
||||
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)}")
|
||||
|
|
@ -486,31 +501,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,
|
||||
|
|
|
|||
|
|
@ -535,3 +535,97 @@ 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):
|
||||
# First hostname resolves public; the redirect target resolves to a
|
||||
# private IP and must be blocked before the 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"
|
||||
)
|
||||
# Only the first (public) hop is issued; the private redirect target is
|
||||
# rejected by validate_url before a second request goes out, and the
|
||||
# method is preserved across the safe redirect loop.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
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"]
|
||||
# Only the first (public) hop was attempted; the private redirect target was
|
||||
# rejected before a second request went out.
|
||||
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"
|
||||
# HTTP target is rewritten to the validated IP; the original hostname rides
|
||||
# in the Host header to defeat DNS rebinding.
|
||||
assert "93.184.216.34" in call["url"]
|
||||
assert call["headers"].get("Host") == "api.example.com"
|
||||
Loading…
Add table
Reference in a new issue