fix(proxy)!: share one destination check between body and path-supplied model

The URL-destination check previously ran over request-body fields only. The
per-field logic moves into reject_url_valued_destination(field, value) so a
deployment name resolved from the request path runs the same check against the
same admin allowlist.

BREAKING CHANGE: a deployment name supplied in the request path that parses as
an http/https destination is now refused. Add the host to
`provider_url_destination_allowed_hosts` in litellm_settings to keep it working.
This commit is contained in:
Yuneng Jiang 2026-08-05 14:14:33 -07:00
parent f047124b5a
commit fc4be70a37
No known key found for this signature in database
5 changed files with 77 additions and 21 deletions

View file

@ -67,7 +67,10 @@ if TYPE_CHECKING:
ProxyConfig = _ProxyConfig
else:
ProxyConfig = Any
from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request
from litellm.proxy.litellm_pre_call_utils import (
add_litellm_data_to_request,
reject_url_valued_destination,
)
from litellm.types.utils import (
ModelResponse,
ModelResponseStream,
@ -1286,6 +1289,9 @@ class ProxyBaseLLMRequestProcessing:
self.data[_metadata_variable_name] = {}
self.data[_metadata_variable_name]["queue_time_seconds"] = queue_time_seconds
if isinstance(model, str):
reject_url_valued_destination("model", model)
self.data["model"] = (
general_settings.get("completion_model", None) # server default
or user_model # model name passed via cli args

View file

@ -70,6 +70,7 @@ async def image_generation(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
model: str | None = None,
):
from litellm.proxy.litellm_pre_call_utils import reject_url_valued_destination
from litellm.proxy.proxy_server import (
add_litellm_data_to_request,
general_settings,
@ -96,6 +97,9 @@ async def image_generation(
proxy_config=proxy_config,
)
if isinstance(model, str):
reject_url_valued_destination("model", model)
data["model"] = (
model
or general_settings.get("image_generation_model", None) # server default

View file

@ -262,29 +262,37 @@ def _reject_url_valued_destinations(data: dict[str, Any]) -> None:
are unaffected, while admins can opt specific hosts back in via
``litellm.provider_url_destination_allowed_hosts``.
"""
allowed_hosts: Final = getattr(litellm, "provider_url_destination_allowed_hosts", []) or []
for field in _URL_DESTINATION_REQUEST_FIELDS:
value = data.get(field)
if not isinstance(value, str):
if isinstance(value, str):
reject_url_valued_destination(field, value)
def reject_url_valued_destination(field: str, value: str) -> None:
"""Reject a URL-valued destination identifier unless admin-allowlisted.
Operates on one field/value pair. ``_reject_url_valued_destinations`` applies
it across ``_URL_DESTINATION_REQUEST_FIELDS`` for a request body.
"""
allowed_hosts: Final = getattr(litellm, "provider_url_destination_allowed_hosts", []) or []
for candidate in provider_url_destination_candidates(value):
if not candidate.lower().startswith(("http://", "https://")):
continue
for candidate in provider_url_destination_candidates(value):
if not candidate.lower().startswith(("http://", "https://")):
continue
if is_url_destination_allowed_by_host(candidate, allowed_hosts):
continue
raise HTTPException(
status_code=400,
detail={
"error": "invalid_request",
"param": field,
"message": (
f"URL-valued '{field}' is not allowed. Configure custom "
"endpoints with api_base instead, or add the destination "
"host to `provider_url_destination_allowed_hosts` in "
"litellm_settings."
),
},
)
if is_url_destination_allowed_by_host(candidate, allowed_hosts):
continue
raise HTTPException(
status_code=400,
detail={
"error": "invalid_request",
"param": field,
"message": (
f"URL-valued '{field}' is not allowed. Configure custom "
"endpoints with api_base instead, or add the destination "
"host to `provider_url_destination_allowed_hosts` in "
"litellm_settings."
),
},
)
def _strip_untrusted_request_header_controls(

View file

@ -120,3 +120,28 @@ def test_azure_image_edit_route(client_no_auth):
assert called_kwargs["prompt"] == "A cute baby sea otter"
assert response.status_code == 200
assert response.json()["data"]
def test_azure_image_generation_route_rejects_url_valued_path_model(client_no_auth):
"""A URL-valued deployment segment is refused before any provider call."""
client, mock_aimage_generation, _ = client_no_auth
response = client.post(
"/openai/deployments/oobabooga/https://example.invalid/images/generations",
json={"prompt": "A cute baby sea otter", "n": 1, "size": "1024x1024"},
)
assert response.status_code == 400
assert "URL-valued" in response.text
mock_aimage_generation.assert_not_called()
def test_azure_image_generation_route_allows_ordinary_path_model(client_no_auth):
"""A deployment name that merely contains a provider prefix still routes."""
client, mock_aimage_generation, _ = client_no_auth
response = client.post(
"/openai/deployments/dall-e-3/images/generations",
json={"prompt": "A cute baby sea otter", "n": 1, "size": "1024x1024"},
)
assert response.status_code == 200
mock_aimage_generation.assert_called_once()

View file

@ -177,3 +177,16 @@ async def test_add_litellm_data_to_request_rejects_url_valued_model():
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail["param"] == "model"
class TestNonStringDestinationValues:
"""Only string identifiers are inspected. Anything else is left alone for the
request's normal validation to handle."""
@pytest.mark.parametrize("value", [123, None, True, {"a": 1}, ["x"], 1.5])
def test_non_string_model_is_ignored(self, value):
_reject_url_valued_destinations({"model": value})
@pytest.mark.parametrize("value", [123, None, True, {"a": 1}, ["x"]])
def test_non_string_file_id_is_ignored(self, value):
_reject_url_valued_destinations({"file_id": value})