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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* fix: normalize ssrf general settings

* fix: clear ssrf allowlists from null settings

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sameer Kankute 2026-07-07 20:49:55 +05:30 • committed by GitHub
parent 4851cba831
commit 42f5b0bd34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 217 additions and 0 deletions

View file

@ -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):

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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: {