From fdae54a896a7e476757279720a64b1dc47bee088 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:12:51 +0000 Subject: [PATCH] fix(guardrails): route custom-code http_request() through SSRF validation --- litellm/litellm_core_utils/url_utils.py | 35 +++++ .../guardrail_hooks/custom_code/primitives.py | 48 +++--- .../litellm_core_utils/test_url_utils.py | 94 ++++++++++++ .../guardrails/test_custom_code_primitives.py | 138 ++++++++++++++++++ 4 files changed, 286 insertions(+), 29 deletions(-) create mode 100644 tests/test_litellm/proxy/guardrails/test_custom_code_primitives.py diff --git a/litellm/litellm_core_utils/url_utils.py b/litellm/litellm_core_utils/url_utils.py index 1cbb1ce973f..fed69979fb2 100644 --- a/litellm/litellm_core_utils/url_utils.py +++ b/litellm/litellm_core_utils/url_utils.py @@ -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") diff --git a/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py b/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py index e60b900428c..05dca41de5c 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py +++ b/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py @@ -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, diff --git a/tests/test_litellm/litellm_core_utils/test_url_utils.py b/tests/test_litellm/litellm_core_utils/test_url_utils.py index cef09f3f2b0..a0da5338811 100644 --- a/tests/test_litellm/litellm_core_utils/test_url_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_url_utils.py @@ -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 diff --git a/tests/test_litellm/proxy/guardrails/test_custom_code_primitives.py b/tests/test_litellm/proxy/guardrails/test_custom_code_primitives.py new file mode 100644 index 00000000000..34faf8b8606 --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/test_custom_code_primitives.py @@ -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"