mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
4851cba831
commit
42f5b0bd34
5 changed files with 217 additions and 0 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
15
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue