Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)
Comment thread
Sameerlite marked this conversation as resolved.


class ConfigYAML(LiteLLMPydanticObjectBase):
Expand Down
35 changes: 35 additions & 0 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3554,6 +3554,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.
Expand Down Expand Up @@ -4541,6 +4563,9 @@ async def load_config(self, router: Optional[litellm.Router], config_file_path:
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)
Expand Down Expand Up @@ -5578,6 +5603,15 @@ async def _update_general_settings(self, db_general_settings: Optional[Json]):
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,
Expand Down Expand Up @@ -14286,6 +14320,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

Expand Down
31 changes: 31 additions & 0 deletions tests/test_litellm/proxy/proxy_server/test_proxy_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
114 changes: 114 additions & 0 deletions tests/test_litellm/proxy/test_proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
"""
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions ui/litellm-dashboard/src/lib/http/schema.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading