From 42f5b0bd34fcd7b01a17f52a41147fb04ab4d06d Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 7 Jul 2026 20:49:55 +0530 Subject: [PATCH] fix(proxy): wire general_settings SSRF allowlist to litellm globals (#32243) * fix(proxy): wire general_settings SSRF allowlist to litellm globals general_settings.user_url_allowed_hosts was documented in SSRF errors but never applied at startup, so internal MCP/OpenAPI URLs stayed blocked. Co-authored-by: Cursor * fix(proxy): regenerate dashboard types and satisfy ruff UP006 budget Use list[str] in ConfigGeneralSettings and run gen:api so schema.d.ts matches the new SSRF general_settings fields. Co-authored-by: Cursor * fix: normalize ssrf general settings * fix: clear ssrf allowlists from null settings --------- Co-authored-by: Cursor --- litellm/proxy/_types.py | 22 ++++ litellm/proxy/proxy_server.py | 35 ++++++ .../proxy/proxy_server/test_proxy_config.py | 31 +++++ tests/test_litellm/proxy/test_proxy_server.py | 114 ++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 15 +++ 5 files changed, 217 insertions(+) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index de35a705c68..e1d657f293b 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2322,6 +2322,28 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): "is active as a reminder that hard enforcement is relaxed." ), ) + user_url_validation: Optional[bool] = Field( + None, + description=( + "Master switch for the SSRF guard applied to user-supplied URLs " + "(image_url, file_url, MCP/OpenAPI spec URLs, etc). Defaults to True. " + "Set to False to disable DNS/IP validation entirely (not recommended)." + ), + ) + user_url_allowed_hosts: Optional[list[str]] = Field( + None, + description=( + "SSRF allowlist for user-supplied URLs. Entries are `hostname` or " + "`hostname:port` (bracketed for IPv6, e.g. `[::1]:8080`). Allowlisted " + "hosts skip the blocked-network check in validate_url() but still " + "resolve DNS. Use this to permit legitimate internal targets, e.g. " + "an internal OpenAPI/MCP server." + ), + ) + provider_url_destination_allowed_hosts: Optional[list[str]] = Field( + None, + description="Allowlist of hosts a request may redirect a provider call's destination URL to.", + ) class ConfigYAML(LiteLLMPydanticObjectBase): diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index c619133cebd..810e94cdc27 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -3566,6 +3566,28 @@ def _scrub_db_overlay_remote_module_loads(section: str, db_value: Any) -> Any: return sanitized +def _normalize_user_url_validation(value: object) -> Optional[bool]: + if value is None: + return None + if isinstance(value, str): + return str_to_bool(value) + return bool(value) + + +def _apply_ssrf_general_settings(settings: Mapping[str, object]) -> None: + if "user_url_allowed_hosts" in settings: + litellm.user_url_allowed_hosts = cast(list[str], settings["user_url_allowed_hosts"]) + + user_url_validation = _normalize_user_url_validation(settings.get("user_url_validation")) + if user_url_validation is not None: + litellm.user_url_validation = user_url_validation + + if "provider_url_destination_allowed_hosts" in settings: + litellm.provider_url_destination_allowed_hosts = cast( + list[str], settings["provider_url_destination_allowed_hosts"] + ) + + class ProxyConfig: """ Abstraction class on top of config loading/updating logic. Gives us one place to control all config updating logic. @@ -4553,6 +4575,9 @@ class ProxyConfig: RoleBasedPermissions(**role_permission) for role_permission in rbac_role_permissions ] + ### SSRF URL VALIDATION SETTINGS ### + _apply_ssrf_general_settings(general_settings) + ## check if user has set a premium feature in general_settings if general_settings.get("enforced_params") is not None and premium_user is not True: raise ValueError("Trying to use `enforced_params`" + CommonProxyErrors.not_premium_user.value) @@ -5590,6 +5615,15 @@ class ProxyConfig: if old_value != new_value: await self._reschedule_spend_log_cleanup_job() + for key in ( + "user_url_allowed_hosts", + "user_url_validation", + "provider_url_destination_allowed_hosts", + ): + if key in _general_settings: + general_settings[key] = _general_settings[key] + _apply_ssrf_general_settings(_general_settings) + def _update_config_fields( self, current_config: dict, @@ -14309,6 +14343,7 @@ async def update_config_general_settings( if data.field_name == "plugins": register_plugins_from_config(general_settings) + _apply_ssrf_general_settings(general_settings) return response diff --git a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py index 9a687addbdd..6cdaa17c0bf 100644 --- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py @@ -720,6 +720,37 @@ async def test_ProxyConfig_load_config_minimal_yaml(tmp_path, monkeypatch): } +@pytest.mark.asyncio +async def test_ProxyConfig_load_config_wires_general_settings_url_validation(tmp_path, monkeypatch): + """Regression for #26599: SSRF settings in general_settings must reach litellm globals.""" + f = tmp_path / "c.yaml" + f.write_text( + "model_list: []\n" + "general_settings:\n" + " user_url_validation: false\n" + " user_url_allowed_hosts:\n" + " - internal.corp\n" + " provider_url_destination_allowed_hosts:\n" + " - api.example.com\n" + ) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) + monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False) + + original_validation = litellm.user_url_validation + original_hosts = list(litellm.user_url_allowed_hosts) + original_provider_hosts = list(litellm.provider_url_destination_allowed_hosts) + try: + await ProxyConfig().load_config(router=None, config_file_path=str(f)) + assert litellm.user_url_validation is False + assert litellm.user_url_allowed_hosts == ["internal.corp"] + assert litellm.provider_url_destination_allowed_hosts == ["api.example.com"] + finally: + litellm.user_url_validation = original_validation + litellm.user_url_allowed_hosts = original_hosts + litellm.provider_url_destination_allowed_hosts = original_provider_hosts + + @pytest.mark.asyncio async def test_ProxyConfig_load_config_missing_file_raises(monkeypatch): monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 533c37e690e..2dc67c827e3 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -2583,6 +2583,50 @@ async def test_load_config_max_budget_env_var_coerced_to_float(tmp_path, monkeyp litellm.max_budget = original_max_budget +@pytest.mark.asyncio +async def test_load_config_user_url_validation_handles_null_and_string_false(tmp_path, monkeypatch): + from litellm.proxy.proxy_server import ProxyConfig + + monkeypatch.setattr(litellm, "user_url_validation", True) + monkeypatch.setattr(litellm, "user_url_allowed_hosts", ["internal.example"]) + monkeypatch.setattr(litellm, "provider_url_destination_allowed_hosts", ["provider.example"]) + null_config_file = tmp_path / "null_config.yaml" + null_config_file.write_text( + yaml.dump( + { + "model_list": [], + "general_settings": { + "user_url_allowed_hosts": None, + "user_url_validation": None, + "provider_url_destination_allowed_hosts": None, + }, + } + ) + ) + + await ProxyConfig().load_config( + router=MagicMock(), config_file_path=str(null_config_file) + ) + assert litellm.user_url_validation is True + assert litellm.user_url_allowed_hosts is None + assert litellm.provider_url_destination_allowed_hosts is None + + false_config_file = tmp_path / "false_config.yaml" + false_config_file.write_text( + yaml.dump( + { + "model_list": [], + "general_settings": {"user_url_validation": "false"}, + } + ) + ) + + await ProxyConfig().load_config( + router=MagicMock(), config_file_path=str(false_config_file) + ) + assert litellm.user_url_validation is False + + @pytest.mark.asyncio async def test_load_environment_variables_direct_and_os_environ(): """ @@ -8871,6 +8915,76 @@ async def test_update_config_general_settings_emits_audit_log(monkeypatch): assert before["some_api_key"] != "sk-stored-secret" +@pytest.mark.asyncio +async def test_update_config_general_settings_applies_ssrf_globals(monkeypatch): + import litellm.proxy.proxy_server as proxy_server_module + from litellm.proxy._types import ConfigFieldUpdate + from litellm.proxy.proxy_server import update_config_general_settings + + fake = _fake_prisma_with_config({}) + monkeypatch.setattr(proxy_server_module, "prisma_client", fake) + monkeypatch.setattr(litellm, "store_audit_logs", False) + monkeypatch.setattr(litellm, "user_url_validation", True) + monkeypatch.setattr(litellm, "user_url_allowed_hosts", []) + monkeypatch.setattr(litellm, "provider_url_destination_allowed_hosts", []) + + admin = UserAPIKeyAuth( + api_key="hashed-admin", + user_id="admin-1", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + await update_config_general_settings( + data=ConfigFieldUpdate( + field_name="user_url_validation", + field_value="false", + config_type="general_settings", + ), + user_api_key_dict=admin, + ) + await update_config_general_settings( + data=ConfigFieldUpdate( + field_name="user_url_allowed_hosts", + field_value=["internal.example"], + config_type="general_settings", + ), + user_api_key_dict=admin, + ) + await update_config_general_settings( + data=ConfigFieldUpdate( + field_name="provider_url_destination_allowed_hosts", + field_value=["provider.example"], + config_type="general_settings", + ), + user_api_key_dict=admin, + ) + await asyncio.sleep(0) + + assert litellm.user_url_validation is False + assert litellm.user_url_allowed_hosts == ["internal.example"] + assert litellm.provider_url_destination_allowed_hosts == ["provider.example"] + + await update_config_general_settings( + data=ConfigFieldUpdate( + field_name="user_url_allowed_hosts", + field_value=None, + config_type="general_settings", + ), + user_api_key_dict=admin, + ) + await update_config_general_settings( + data=ConfigFieldUpdate( + field_name="provider_url_destination_allowed_hosts", + field_value=None, + config_type="general_settings", + ), + user_api_key_dict=admin, + ) + await asyncio.sleep(0) + + assert litellm.user_url_allowed_hosts is None + assert litellm.provider_url_destination_allowed_hosts is None + + @pytest.mark.asyncio async def test_delete_config_general_settings_emits_deleted_audit_log(monkeypatch): import litellm.proxy.proxy_server as proxy_server_module diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 496a0462ebe..71568299529 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -22508,6 +22508,11 @@ export interface components { * @description external services registered as embeddable UI plugins */ plugins?: components["schemas"]["PluginConfig"][] | null; + /** + * Provider Url Destination Allowed Hosts + * @description Allowlist of hosts a request may redirect a provider call's destination URL to. + */ + provider_url_destination_allowed_hosts?: string[] | null; /** * Reject Clientside Metadata Tags * @description When set to True, rejects requests that contain client-side 'metadata.tags' to prevent users from influencing budgets by sending different tags. Tags can only be inherited from the API key metadata. @@ -22566,6 +22571,16 @@ export interface components { * @description Controls how non-admin users interact with MCP servers in the dashboard. 'restricted' shows only accessible servers, 'view_all' lists every server in read-only mode. */ user_mcp_management_mode?: ("restricted" | "view_all") | null; + /** + * User Url Allowed Hosts + * @description SSRF allowlist for user-supplied URLs. Entries are `hostname` or `hostname:port` (bracketed for IPv6, e.g. `[::1]:8080`). Allowlisted hosts skip the blocked-network check in validate_url() but still resolve DNS. Use this to permit legitimate internal targets, e.g. an internal OpenAPI/MCP server. + */ + user_url_allowed_hosts?: string[] | null; + /** + * User Url Validation + * @description Master switch for the SSRF guard applied to user-supplied URLs (image_url, file_url, MCP/OpenAPI spec URLs, etc). Defaults to True. Set to False to disable DNS/IP validation entirely (not recommended). + */ + user_url_validation?: boolean | null; }; /** ConfigList */ ConfigList: {