From d8ce7863349f0de9ccf04565c1e2c25695111432 Mon Sep 17 00:00:00 2001 From: maycuatroi1 <5876946+maycuatroi1@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:23:20 +0700 Subject: [PATCH 1/3] fix(guardrails): restore SSRF protection in custom code HTTP primitives The custom code guardrail sandbox exposes http_get/http_post/http_request so guardrails can call external moderation APIs. The SSRF controls from #25004 (_validate_url_for_ssrf + follow_redirects=False) were dropped in the Starlark -> RestrictedPython sandbox migration, so guardrail code could reach loopback, RFC1918, and cloud metadata endpoints again, and all five HTTP methods followed redirects. Route the primitives through the canonical validator instead of restoring the old hand-rolled check: - validate_url() blocks non-globally-routable targets before connecting and eliminates DNS rebinding for plain http via resolve-and-rewrite - operators can allow specific internal hosts via user_url_allowed_hosts in general_settings, or disable validation with litellm.user_url_validation = False - follow_redirects=False on GET/POST/PUT/DELETE/PATCH so a 3xx cannot bypass validation Adds regression tests to test_custom_code_security.py covering blocked targets, the allowlist and master-switch escape hatches, Host header rewriting, and redirect refusal on every method. --- .../guardrail_hooks/custom_code/primitives.py | 69 ++++++- .../guardrails/test_custom_code_security.py | 176 +++++++++++++++--- 2 files changed, 212 insertions(+), 33 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py b/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py index 864ec052543..1553487ee67 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py +++ b/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py @@ -12,7 +12,9 @@ from urllib.parse import urlparse import httpx +import litellm from litellm._logging import verbose_proxy_logger +from litellm.litellm_core_utils.url_utils import SSRFError, validate_url from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.types.llms.custom_http import httpxSpecialProvider @@ -417,6 +419,14 @@ async def http_request( Uses LiteLLM's global cached AsyncHTTPHandler for connection pooling and better performance. + SSRF protection: the URL is resolved and validated (via + ``litellm_core_utils.url_utils.validate_url``) before connecting, and + redirects are never followed. Requests targeting loopback, private, + link-local, or cloud-metadata addresses are blocked. Operators can + allow specific internal hosts via ``user_url_allowed_hosts`` in + ``general_settings``, or disable validation with + ``litellm.user_url_validation = False``. + Args: url: The URL to request method: HTTP method (GET, POST, PUT, DELETE, PATCH). Defaults to GET. @@ -450,6 +460,19 @@ async def http_request( if not is_valid_url(url): return _http_error_response(f"Invalid URL: {url}") + # SSRF protection: block requests that resolve to internal/private/metadata + # targets before any connection is made. Legitimate internal services can + # be reached by adding them to `user_url_allowed_hosts` in general_settings. + # `litellm.user_url_validation = False` disables this check entirely. + validated_url = url + if getattr(litellm, "user_url_validation", True): + try: + validated_url, host_header = validate_url(url) + headers = {**(headers or {}), "Host": host_header} + except SSRFError as e: + verbose_proxy_logger.warning("Custom code http_request SSRF blocked: %s", e) + return _http_error_response(f"Blocked: {e}") + # Validate and normalize method method = method.upper() allowed_methods: Final = {"GET", "POST", "PUT", "DELETE", "PATCH"} @@ -469,7 +492,7 @@ async def http_request( ) try: - response: Final = await _execute_http_request(client, method, url, headers, body, timeout) + response: Final = await _execute_http_request(client, method, validated_url, headers, body, timeout) return _http_success_response(response) except httpx.TimeoutException as e: @@ -494,19 +517,51 @@ async def _execute_http_request( body: Any | None, timeout: float, ) -> httpx.Response: - """Execute the HTTP request using the appropriate client method.""" + """Execute the HTTP request using the appropriate client method. + + Redirects are disabled on every method so a 3xx response cannot bypass + the SSRF validation performed by the caller. + """ json_body, data_body = _prepare_http_body(body) if method == "GET": - return await client.get(url=url, headers=headers) + return await client.get(url=url, headers=headers, follow_redirects=False) elif method == "POST": - return await client.post(url=url, headers=headers, json=json_body, data=data_body, timeout=timeout) + return await client.post( + url=url, + headers=headers, + json=json_body, + data=data_body, + timeout=timeout, + follow_redirects=False, + ) elif method == "PUT": - return await client.put(url=url, headers=headers, json=json_body, data=data_body, timeout=timeout) + return await client.put( + url=url, + headers=headers, + json=json_body, + data=data_body, + timeout=timeout, + follow_redirects=False, + ) elif method == "DELETE": - return await client.delete(url=url, headers=headers, json=json_body, data=data_body, timeout=timeout) + return await client.delete( + url=url, + headers=headers, + json=json_body, + data=data_body, + timeout=timeout, + follow_redirects=False, + ) elif method == "PATCH": - return await client.patch(url=url, headers=headers, json=json_body, data=data_body, timeout=timeout) + return await client.patch( + url=url, + headers=headers, + json=json_body, + data=data_body, + timeout=timeout, + follow_redirects=False, + ) else: raise ValueError(f"Unsupported HTTP method: {method}") diff --git a/tests/test_litellm/proxy/guardrails/test_custom_code_security.py b/tests/test_litellm/proxy/guardrails/test_custom_code_security.py index f93ecfc3010..b6e7f3b7025 100644 --- a/tests/test_litellm/proxy/guardrails/test_custom_code_security.py +++ b/tests/test_litellm/proxy/guardrails/test_custom_code_security.py @@ -1,6 +1,10 @@ +import socket + +import httpx import pytest from fastapi import HTTPException +import litellm from litellm.exceptions import ModifyResponseException from litellm.proxy.guardrails.guardrail_hooks.custom_code.custom_code_guardrail import ( CustomCodeCompilationError, @@ -77,18 +81,14 @@ def test_nfkc_homoglyph_rejected_at_compile(): [ # Literal dunder attribute access. "def apply_guardrail(i, r, t):\n return str.__class__\n", - "def apply_guardrail(i, r, t):\n" - " return ().__class__.__bases__[0].__subclasses__()\n", + "def apply_guardrail(i, r, t):\n return ().__class__.__bases__[0].__subclasses__()\n", # gi_code — on the transformer's restricted-names list. - "def apply_guardrail(i, r, t):\n" - " def g():\n yield 1\n" - " return g().gi_code\n", + "def apply_guardrail(i, r, t):\n def g():\n yield 1\n return g().gi_code\n", # Import forms. "import os\ndef apply_guardrail(i, r, t):\n return allow()\n", - "from subprocess import call\n" - "def apply_guardrail(i, r, t):\n return allow()\n", + "from subprocess import call\ndef apply_guardrail(i, r, t):\n return allow()\n", # __import__ is rejected as an underscore-prefixed name. - "def apply_guardrail(i, r, t):\n" ' return __import__("os")\n', + 'def apply_guardrail(i, r, t):\n return __import__("os")\n', ], ) def test_compile_time_rejections(snippet: str): @@ -100,8 +100,7 @@ def test_compile_time_rejections(snippet: str): "snippet", [ # getattr is not in the sandbox builtins — NameError at call time. - "def apply_guardrail(i, r, t):\n" - ' return getattr(str, "_"+"_class_"+"_")\n', + 'def apply_guardrail(i, r, t):\n return getattr(str, "_"+"_class_"+"_")\n', # setattr is guarded_setattr + full_write_guard — setting any attribute # on a user-defined object raises TypeError, whether the name is a # dunder or not. @@ -139,10 +138,7 @@ def test_documented_ssn_example_compiles_and_runs(): @pytest.mark.asyncio async def test_async_guardrail_compiles_and_runs(): - code = ( - "async def apply_guardrail(inputs, request_data, input_type):\n" - " return allow()\n" - ) + code = "async def apply_guardrail(inputs, request_data, input_type):\n return allow()\n" guardrail = _compile(code) from litellm.types.utils import GenericGuardrailAPIInputs @@ -156,10 +152,7 @@ async def test_async_guardrail_compiles_and_runs(): @pytest.mark.asyncio async def test_custom_code_pre_call_block_uses_passthrough(): - code = ( - "def apply_guardrail(inputs, request_data, input_type):\n" - ' return block("blocked by test")\n' - ) + code = 'def apply_guardrail(inputs, request_data, input_type):\n return block("blocked by test")\n' guardrail = _compile(code) with pytest.raises(ModifyResponseException) as exc_info: @@ -176,10 +169,7 @@ async def test_custom_code_pre_call_block_uses_passthrough(): @pytest.mark.asyncio async def test_custom_code_post_call_block_raises_http_400(): - code = ( - "def apply_guardrail(inputs, request_data, input_type):\n" - ' return block("blocked by test")\n' - ) + code = 'def apply_guardrail(inputs, request_data, input_type):\n return block("blocked by test")\n' guardrail = _compile(code) with pytest.raises(HTTPException) as exc_info: @@ -198,10 +188,7 @@ async def test_custom_code_post_call_block_raises_http_400(): def test_typical_sync_guardrail_still_works(): - code = ( - "def apply_guardrail(inputs, request_data, input_type):\n" - " return allow()\n" - ) + code = "def apply_guardrail(inputs, request_data, input_type):\n return allow()\n" guardrail = _compile(code) assert guardrail._compiled_function is not None @@ -228,3 +215,140 @@ def test_augmented_assignment_works(): def test_missing_apply_guardrail_raises(): with pytest.raises(CustomCodeCompilationError, match="apply_guardrail"): _compile("x = 1\n") + + +# --- SSRF protection on the HTTP primitives --------------------------------- +# +# The primitives run inside the sandbox but talk to the network with the +# proxy process's privileges. http_request/http_get/http_post must therefore +# refuse loopback, private, and cloud-metadata targets and must not follow +# redirects (a 302 to an internal address would otherwise bypass the check). + + +class _FakeAsyncClient: + def __init__(self, response=None): + self.response = response + self.calls = [] + + async def _record(self, **kwargs): + self.calls.append(kwargs) + if isinstance(self.response, Exception): + raise self.response + return self.response + + async def get(self, **kwargs): + return await self._record(**kwargs) + + async def post(self, **kwargs): + return await self._record(**kwargs) + + async def put(self, **kwargs): + return await self._record(**kwargs) + + async def delete(self, **kwargs): + return await self._record(**kwargs) + + async def patch(self, **kwargs): + return await self._record(**kwargs) + + +def _ok_response(status_code=200): + return httpx.Response(status_code, request=httpx.Request("GET", "http://ok")) + + +@pytest.fixture +def _public_dns(monkeypatch): + """Point every hostname at a globally routable IP so tests stay hermetic.""" + + def fake_getaddrinfo(host, port, *args, **kwargs): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", port))] + + monkeypatch.setattr("litellm.litellm_core_utils.url_utils.socket.getaddrinfo", fake_getaddrinfo) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "url", + [ + "http://127.0.0.1:8971/", + "http://localhost/admin", + "http://10.1.2.3/", + "http://192.168.1.1/", + "http://169.254.169.254/latest/meta-data/iam/security-credentials/", + "http://[::1]/", + ], +) +async def test_http_primitives_block_internal_targets(url, monkeypatch): + from litellm.proxy.guardrails.guardrail_hooks.custom_code import primitives + + client = _FakeAsyncClient() + monkeypatch.setattr(primitives, "get_async_httpx_client", lambda **kwargs: client) + + result = await primitives.http_request(url) + + assert result["success"] is False + assert result["status_code"] == 0 + assert "Blocked" in result["error"] + assert client.calls == [] + + +@pytest.mark.asyncio +async def test_http_primitives_allow_public_url(monkeypatch, _public_dns): + from litellm.proxy.guardrails.guardrail_hooks.custom_code import primitives + + client = _FakeAsyncClient(_ok_response()) + monkeypatch.setattr(primitives, "get_async_httpx_client", lambda **kwargs: client) + + result = await primitives.http_get("http://moderation.example.com/v1/check", headers={"Authorization": "Bearer t"}) + + assert result["success"] is True + assert len(client.calls) == 1 + call = client.calls[0] + assert call["url"] == "http://93.184.216.34/v1/check" + assert call["headers"]["Host"] == "moderation.example.com" + assert call["headers"]["Authorization"] == "Bearer t" + assert call["follow_redirects"] is False + + +@pytest.mark.asyncio +async def test_http_primitives_honor_allowlisted_internal_host(monkeypatch, _public_dns): + from litellm.proxy.guardrails.guardrail_hooks.custom_code import primitives + + monkeypatch.setattr(litellm, "user_url_allowed_hosts", ["internal-moderation.corp"], raising=False) + client = _FakeAsyncClient(_ok_response()) + monkeypatch.setattr(primitives, "get_async_httpx_client", lambda **kwargs: client) + + result = await primitives.http_get("http://internal-moderation.corp/check") + + assert result["success"] is True + assert client.calls[0]["follow_redirects"] is False + + +@pytest.mark.asyncio +async def test_http_primitives_validation_can_be_disabled(monkeypatch, _public_dns): + from litellm.proxy.guardrails.guardrail_hooks.custom_code import primitives + + monkeypatch.setattr(litellm, "user_url_validation", False, raising=False) + client = _FakeAsyncClient(_ok_response()) + monkeypatch.setattr(primitives, "get_async_httpx_client", lambda **kwargs: client) + + result = await primitives.http_get("http://10.1.2.3/internal") + + assert result["success"] is True + assert client.calls[0]["url"] == "http://10.1.2.3/internal" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["POST", "PUT", "DELETE", "PATCH"]) +async def test_http_primitives_do_not_follow_redirects(method, monkeypatch, _public_dns): + from litellm.proxy.guardrails.guardrail_hooks.custom_code import primitives + + client = _FakeAsyncClient(_ok_response(status_code=302)) + monkeypatch.setattr(primitives, "get_async_httpx_client", lambda **kwargs: client) + + result = await primitives.http_request("http://moderation.example.com/v1/check", method=method, body={"text": "hi"}) + + assert len(client.calls) == 1 + assert client.calls[0]["follow_redirects"] is False + assert result["status_code"] == 302 + assert result["success"] is False From cc7646ea172a0a3540fa1e7f6f5c5e41a2014daa Mon Sep 17 00:00:00 2001 From: maycuatroi1 <5876946+maycuatroi1@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:36:53 +0700 Subject: [PATCH 2/3] fix(guardrails): address review - support follow_redirects on non-GET, malformed ports Review findings on the previous commit: 1. AsyncHTTPHandler.post/put/patch/delete did not accept follow_redirects (only get did), so the primitives TypeError-d before sending non-GET requests. Add the parameter to all four methods plus the connection retry path, mirroring the existing get() pattern: default None resolves to USE_CLIENT_DEFAULT, so existing callers keep the client default. 2. URLs with a malformed port (e.g. http://host:99999/) pass is_valid_url but urlparse raises ValueError when validate_url reads parsed.port, escaping http_request instead of returning the structured error. Catch ValueError alongside SSRFError so the sandboxed guardrail always gets the error dict. 3. Tests now use a fake client with the production method signatures so kwarg mismatches fail in tests, plus an inspect.signature guard that fails if AsyncHTTPHandler ever drops follow_redirects again. --- litellm/llms/custom_httpx/http_handler.py | 24 +++- .../guardrail_hooks/custom_code/primitives.py | 2 +- .../guardrails/test_custom_code_security.py | 135 ++++++++++++++++-- 3 files changed, 145 insertions(+), 16 deletions(-) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 52f30e31641..6cb281cc700 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -664,7 +664,9 @@ class AsyncHTTPHandler: logging_obj: LiteLLMLoggingObject | None = None, files: RequestFiles | None = None, content: _RequestContent | None = None, + follow_redirects: bool | None = None, ): + _follow_redirects: Final = follow_redirects if follow_redirects is not None else USE_CLIENT_DEFAULT start_time: Final = time.time() try: if timeout is None: @@ -684,7 +686,7 @@ class AsyncHTTPHandler: files=files, content=request_content, ) - response: Final = await self.client.send(req, stream=stream) + response: Final = await self.client.send(req, stream=stream, follow_redirects=_follow_redirects) response.raise_for_status() return response except (httpx.RemoteProtocolError, httpx.ConnectError): @@ -699,6 +701,7 @@ class AsyncHTTPHandler: params=params, headers=headers, stream=stream, + follow_redirects=follow_redirects, ) finally: await new_client.aclose() @@ -732,7 +735,9 @@ class AsyncHTTPHandler: timeout: float | httpx.Timeout | None = None, stream: bool = False, content: _RequestContent | None = None, + follow_redirects: bool | None = None, ): + _follow_redirects: Final = follow_redirects if follow_redirects is not None else USE_CLIENT_DEFAULT try: if timeout is None: timeout = self.timeout @@ -750,7 +755,7 @@ class AsyncHTTPHandler: timeout=timeout, content=request_content, ) - response: Final = await self.client.send(req) + response: Final = await self.client.send(req, follow_redirects=_follow_redirects) response.raise_for_status() return response except (httpx.RemoteProtocolError, httpx.ConnectError): @@ -765,6 +770,7 @@ class AsyncHTTPHandler: params=params, headers=headers, stream=stream, + follow_redirects=follow_redirects, ) finally: await new_client.aclose() @@ -796,7 +802,9 @@ class AsyncHTTPHandler: timeout: float | httpx.Timeout | None = None, stream: bool = False, content: _RequestContent | None = None, + follow_redirects: bool | None = None, ): + _follow_redirects: Final = follow_redirects if follow_redirects is not None else USE_CLIENT_DEFAULT try: if timeout is None: timeout = self.timeout @@ -814,7 +822,7 @@ class AsyncHTTPHandler: timeout=timeout, content=request_content, ) - response: Final = await self.client.send(req) + response: Final = await self.client.send(req, follow_redirects=_follow_redirects) response.raise_for_status() return response except (httpx.RemoteProtocolError, httpx.ConnectError): @@ -829,6 +837,7 @@ class AsyncHTTPHandler: params=params, headers=headers, stream=stream, + follow_redirects=follow_redirects, ) finally: await new_client.aclose() @@ -860,7 +869,9 @@ class AsyncHTTPHandler: timeout: float | httpx.Timeout | None = None, stream: bool = False, content: _RequestContent | None = None, + follow_redirects: bool | None = None, ): + _follow_redirects: Final = follow_redirects if follow_redirects is not None else USE_CLIENT_DEFAULT try: if timeout is None: timeout = self.timeout @@ -878,7 +889,7 @@ class AsyncHTTPHandler: timeout=timeout, content=request_content, ) - response: Final = await self.client.send(req, stream=stream) + response: Final = await self.client.send(req, stream=stream, follow_redirects=_follow_redirects) response.raise_for_status() return response except (httpx.RemoteProtocolError, httpx.ConnectError): @@ -893,6 +904,7 @@ class AsyncHTTPHandler: params=params, headers=headers, stream=stream, + follow_redirects=follow_redirects, ) finally: await new_client.aclose() @@ -911,12 +923,14 @@ class AsyncHTTPHandler: headers: dict | None = None, stream: bool = False, content: _RequestContent | None = None, + follow_redirects: bool | None = None, ): """ Making POST request for a single connection client. Used for retrying connection client errors. """ + _follow_redirects: Final = follow_redirects if follow_redirects is not None else USE_CLIENT_DEFAULT # Prepare data/content parameters to prevent httpx DeprecationWarning (memory leak fix) request_data, request_content = _prepare_request_data_and_content(data, content) @@ -929,7 +943,7 @@ class AsyncHTTPHandler: headers=headers, content=request_content, ) - response: Final = await client.send(req, stream=stream) + response: Final = await client.send(req, stream=stream, follow_redirects=_follow_redirects) response.raise_for_status() return response diff --git a/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py b/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py index 1553487ee67..89eb79e4ce7 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py +++ b/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py @@ -469,7 +469,7 @@ async def http_request( try: validated_url, host_header = validate_url(url) headers = {**(headers or {}), "Host": host_header} - except SSRFError as e: + except (SSRFError, ValueError) as e: verbose_proxy_logger.warning("Custom code http_request SSRF blocked: %s", e) return _http_error_response(f"Blocked: {e}") diff --git a/tests/test_litellm/proxy/guardrails/test_custom_code_security.py b/tests/test_litellm/proxy/guardrails/test_custom_code_security.py index b6e7f3b7025..74c85dbc3e2 100644 --- a/tests/test_litellm/proxy/guardrails/test_custom_code_security.py +++ b/tests/test_litellm/proxy/guardrails/test_custom_code_security.py @@ -1,3 +1,4 @@ +import inspect import socket import httpx @@ -226,6 +227,12 @@ def test_missing_apply_guardrail_raises(): class _FakeAsyncClient: + """Mimics the REAL AsyncHTTPHandler method signatures. + + If the primitives pass a kwarg the production handler does not accept, + these fakes raise TypeError exactly like production would. + """ + def __init__(self, response=None): self.response = response self.calls = [] @@ -236,20 +243,123 @@ class _FakeAsyncClient: raise self.response return self.response - async def get(self, **kwargs): - return await self._record(**kwargs) + async def get( + self, + url, + params=None, + headers=None, + follow_redirects=None, + timeout=None, + ): + return await self._record( + url=url, + params=params, + headers=headers, + follow_redirects=follow_redirects, + timeout=timeout, + ) - async def post(self, **kwargs): - return await self._record(**kwargs) + async def post( + self, + url, + data=None, + json=None, + params=None, + headers=None, + timeout=None, + stream=False, + logging_obj=None, + files=None, + content=None, + follow_redirects=None, + ): + return await self._record( + url=url, + data=data, + json=json, + params=params, + headers=headers, + timeout=timeout, + follow_redirects=follow_redirects, + ) - async def put(self, **kwargs): - return await self._record(**kwargs) + async def put( + self, + url, + data=None, + json=None, + params=None, + headers=None, + timeout=None, + stream=False, + content=None, + follow_redirects=None, + ): + return await self._record( + url=url, + data=data, + json=json, + params=params, + headers=headers, + timeout=timeout, + follow_redirects=follow_redirects, + ) - async def delete(self, **kwargs): - return await self._record(**kwargs) + async def patch( + self, + url, + data=None, + json=None, + params=None, + headers=None, + timeout=None, + stream=False, + content=None, + follow_redirects=None, + ): + return await self._record( + url=url, + data=data, + json=json, + params=params, + headers=headers, + timeout=timeout, + follow_redirects=follow_redirects, + ) - async def patch(self, **kwargs): - return await self._record(**kwargs) + async def delete( + self, + url, + data=None, + json=None, + params=None, + headers=None, + timeout=None, + stream=False, + content=None, + follow_redirects=None, + ): + return await self._record( + url=url, + data=data, + json=json, + params=params, + headers=headers, + timeout=timeout, + follow_redirects=follow_redirects, + ) + + +@pytest.mark.parametrize("method", ["get", "post", "put", "patch", "delete"]) +def test_async_http_handler_accepts_follow_redirects(method): + from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler + + params = inspect.signature(getattr(AsyncHTTPHandler, method)).parameters + assert "follow_redirects" in params, ( + f"AsyncHTTPHandler.{method} must accept follow_redirects or the " + "guardrail HTTP primitives cannot disable redirects" + ) + assert params["follow_redirects"].default is None def _ok_response(status_code=200): @@ -276,6 +386,11 @@ def _public_dns(monkeypatch): "http://192.168.1.1/", "http://169.254.169.254/latest/meta-data/iam/security-credentials/", "http://[::1]/", + # Malformed port: is_valid_url accepts it (scheme + netloc), but + # urlparse raises ValueError when validate_url reads parsed.port. + # Must surface as a structured error, not an escaped exception. + "http://example.com:99999/", + "http://example.com:notaport/", ], ) async def test_http_primitives_block_internal_targets(url, monkeypatch): From 059f1fcad45e4b58d36de994946facc684f8701c Mon Sep 17 00:00:00 2001 From: maycuatroi1 <5876946+maycuatroi1@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:56:55 +0700 Subject: [PATCH 3/3] fix(guardrails): satisfy type-discipline budget on the SSRF validation block http_request re-bound the `headers` parameter when merging the restored Host header (LIT011, budget +1 over the ceiling). Bind `request_headers` instead and pass that to _execute_http_request. --- .../guardrails/guardrail_hooks/custom_code/primitives.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py b/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py index 89eb79e4ce7..431e7c11a7a 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py +++ b/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py @@ -465,10 +465,11 @@ async def http_request( # be reached by adding them to `user_url_allowed_hosts` in general_settings. # `litellm.user_url_validation = False` disables this check entirely. validated_url = url + request_headers = headers if getattr(litellm, "user_url_validation", True): try: validated_url, host_header = validate_url(url) - headers = {**(headers or {}), "Host": host_header} + request_headers = {**(headers or {}), "Host": host_header} except (SSRFError, ValueError) as e: verbose_proxy_logger.warning("Custom code http_request SSRF blocked: %s", e) return _http_error_response(f"Blocked: {e}") @@ -492,7 +493,7 @@ async def http_request( ) try: - response: Final = await _execute_http_request(client, method, validated_url, headers, body, timeout) + response: Final = await _execute_http_request(client, method, validated_url, request_headers, body, timeout) return _http_success_response(response) except httpx.TimeoutException as e: