From f512075556c29a1d0c22037e964b386e377b383b Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Sun, 31 May 2026 05:33:04 +0000 Subject: [PATCH] chore(proxy): SSRF-guard the /health/test_connection destination The connection-test endpoint accepts a request-supplied api_base and issues a server-side request to it, so a team admin could point it at an internal or metadata address. Validate the resolved destination through the existing url_utils guard (gated on user_url_validation, default on, with user_url_allowed_hosts as the allowlist), the same policy already applied to request-time api_base. This complements the existing refusal to forward an inherited credential to an overridden destination. --- .../health_endpoints/_health_endpoints.py | 23 +++++++++++++++++++ .../health_endpoints/test_health_endpoints.py | 19 +++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index b6210cf7276..2e40550a227 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -14,6 +14,7 @@ import litellm from litellm._logging import verbose_logger, verbose_proxy_logger from litellm.constants import HEALTH_CHECK_TIMEOUT_SECONDS from litellm.litellm_core_utils.custom_logger_registry import CustomLoggerRegistry +from litellm.litellm_core_utils.url_utils import SSRFError, validate_url from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.proxy._types import ( AlertType, @@ -121,6 +122,26 @@ def _reject_inherited_credential_redirect( ) +def _reject_ssrf_destination(litellm_params: dict) -> None: + """Block a (possibly request-overridden) api_base/base_url that resolves to an + internal/metadata target. Gated on litellm.user_url_validation (default True) + with user_url_allowed_hosts as the allowlist escape hatch, mirroring the + request-time guard so /health/test_connection cannot be used for SSRF.""" + if not getattr(litellm, "user_url_validation", False): + return + for url_field in ("api_base", "base_url"): + url_value = litellm_params.get(url_field) + if not isinstance(url_value, str) or not url_value: + continue + try: + validate_url(url_value) + except SSRFError as e: + raise HTTPException( + status_code=400, + detail={"error": f"{url_field} is rejected by the SSRF guard: {e}"}, + ) + + def get_callback_identifier(callback): """ Get the callback identifier string, handling both strings and objects. @@ -1893,6 +1914,8 @@ async def test_model_connection( # noqa: PLR0915 config_litellm_params=config_litellm_params, request_litellm_params=request_litellm_params, ) + # Block SSRF to internal/metadata targets via the (overridden) destination. + _reject_ssrf_destination(litellm_params) ## Auth check — when the deployment was resolved by id, authorize against ## its real owner (model_info), not the caller-supplied model_info, so a diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index 58431ad5994..65807398f42 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -1978,3 +1978,22 @@ def test_reject_inherited_credential_redirect_helper(): config_litellm_params={"api_key": "sk-x"}, request_litellm_params={"model": "gpt-4o"}, ) + + +def test_reject_ssrf_destination_helper(): + import litellm + from fastapi import HTTPException + + from litellm.proxy.health_endpoints._health_endpoints import ( + _reject_ssrf_destination, + ) + + # Internal/metadata target is rejected when URL validation is on. + with patch.object(litellm, "user_url_validation", True): + with pytest.raises(HTTPException): + _reject_ssrf_destination( + {"api_base": "http://169.254.169.254/latest/meta-data/"} + ) + # The opt-out toggle is honored (internal endpoints / Ollama). + with patch.object(litellm, "user_url_validation", False): + _reject_ssrf_destination({"api_base": "http://127.0.0.1:8080"})