mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-03 07:23:33 +00:00
Merge 9d972c8028 into dddbaa803d
This commit is contained in:
commit
80456cc71e
4 changed files with 188 additions and 10 deletions
|
|
@ -2761,6 +2761,14 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
description="How long a blocked source address, or source address and username, stays blocked. Every attempt from a blocked key, right or wrong, is refused with 429 before the password is checked; the block is not extended by refused attempts. Set under `general_settings` in config.yaml. Defaults to 300",
|
||||
)
|
||||
allowed_routes: list | None = Field(None, description="Proxy API Endpoints you want users to be able to access")
|
||||
allowed_client_side_s3_buckets: tuple[str, ...] | None = Field(
|
||||
None,
|
||||
description="S3 bucket names a client may send as `s3_bucket_name` or `s3_output_bucket_name` in a request body, e.g. on /v1/batches. Any other bucket is rejected unless client-side credentials are allowed",
|
||||
)
|
||||
allowed_client_side_s3_endpoint_urls: tuple[str, ...] | None = Field(
|
||||
None,
|
||||
description="Exact `s3_endpoint_url` values a client may send in a request body. Any other value is rejected unless client-side credentials are allowed",
|
||||
)
|
||||
reject_clientside_metadata_tags: bool | None = Field(
|
||||
None,
|
||||
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.",
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ from functools import lru_cache
|
|||
from itertools import accumulate, chain, repeat
|
||||
from logging import Logger
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final, Protocol
|
||||
from typing import Any, Final, Literal, Protocol, TypeAlias
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from pydantic import PositiveInt, TypeAdapter, ValidationError
|
||||
from pydantic import BaseModel, ConfigDict, PositiveInt, TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
from litellm import Router, constants, provider_list
|
||||
|
|
@ -438,6 +438,9 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = (
|
|||
# S3; ``sagemaker_base_url`` redirects all SageMaker traffic;
|
||||
# ``deployment_url`` redirects SAP deployments.
|
||||
"s3_endpoint_url",
|
||||
"s3_bucket_name",
|
||||
"s3_output_bucket_name",
|
||||
"allow_legacy_cloud_file_ids",
|
||||
"sagemaker_base_url",
|
||||
"deployment_url",
|
||||
# NVIDIA Riva fields consumed by the audio-transcription handler
|
||||
|
|
@ -475,6 +478,48 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = (
|
|||
)
|
||||
|
||||
|
||||
_ClientSideAllowlistSetting: TypeAlias = Literal[
|
||||
"allowed_client_side_s3_buckets", "allowed_client_side_s3_endpoint_urls"
|
||||
]
|
||||
|
||||
_PROXY_ALLOWLIST_SETTING_BY_PARAM: Final[Mapping[str, _ClientSideAllowlistSetting]] = MappingProxyType(
|
||||
{
|
||||
"s3_bucket_name": "allowed_client_side_s3_buckets",
|
||||
"s3_output_bucket_name": "allowed_client_side_s3_buckets",
|
||||
"s3_endpoint_url": "allowed_client_side_s3_endpoint_urls",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _ClientSideStorageAllowlist(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
allowed_client_side_s3_buckets: tuple[str, ...] | None = None
|
||||
allowed_client_side_s3_endpoint_urls: tuple[str, ...] | None = None
|
||||
|
||||
def admits(self, setting: _ClientSideAllowlistSetting, value: object) -> bool:
|
||||
match setting:
|
||||
case "allowed_client_side_s3_buckets":
|
||||
return value in (self.allowed_client_side_s3_buckets or ())
|
||||
case "allowed_client_side_s3_endpoint_urls":
|
||||
return value in (self.allowed_client_side_s3_endpoint_urls or ())
|
||||
|
||||
|
||||
def _banned_param_error(param: str) -> ValueError:
|
||||
setting: Final = _PROXY_ALLOWLIST_SETTING_BY_PARAM.get(param)
|
||||
allowlist_hint: Final = (
|
||||
"" if setting is None else f" To allow specific values only, list them in `general_settings.{setting}`."
|
||||
)
|
||||
return ValueError(
|
||||
f"Rejected Request: {param} is not allowed in request body. "
|
||||
"Clientside passthrough requires explicit admin opt-in via "
|
||||
"either `general_settings.allow_client_side_credentials = true` "
|
||||
"(proxy-wide) or `configurable_clientside_auth_params` on the "
|
||||
"deployment in your proxy config.yaml. "
|
||||
f"Relevant Issue: https://huntr.com/bounties/4001e1a2-7b7a-4776-a3ae-e6692ec3d997{allowlist_hint}",
|
||||
)
|
||||
|
||||
|
||||
def _check_banned_params(
|
||||
body: Mapping[str, object],
|
||||
general_settings: dict,
|
||||
|
|
@ -504,6 +549,10 @@ def _check_banned_params(
|
|||
# Proxy-wide opt-in: every banned param is permitted, exit
|
||||
# entirely so the rest of the loop doesn't waste work.
|
||||
return
|
||||
if (
|
||||
setting := _PROXY_ALLOWLIST_SETTING_BY_PARAM.get(param)
|
||||
) is not None and _ClientSideStorageAllowlist.model_validate(general_settings).admits(setting, body[param]):
|
||||
continue
|
||||
if (
|
||||
_allow_model_level_clientside_configurable_parameters(
|
||||
model=model,
|
||||
|
|
@ -519,14 +568,7 @@ def _check_banned_params(
|
|||
# ``api_base`` with an unallowed ``langfuse_host`` is still
|
||||
# rejected for the second field.
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Rejected Request: {param} is not allowed in request body. "
|
||||
"Clientside passthrough requires explicit admin opt-in via "
|
||||
"either `general_settings.allow_client_side_credentials = true` "
|
||||
"(proxy-wide) or `configurable_clientside_auth_params` on the "
|
||||
"deployment in your proxy config.yaml. "
|
||||
"Relevant Issue: https://huntr.com/bounties/4001e1a2-7b7a-4776-a3ae-e6692ec3d997",
|
||||
)
|
||||
raise _banned_param_error(param)
|
||||
|
||||
|
||||
_FALLBACK_FIELDS: Final[tuple[str, ...]] = (
|
||||
|
|
|
|||
|
|
@ -5265,6 +5265,124 @@ def test_get_end_user_id_from_request_body_backwards_compatibility():
|
|||
assert get_end_user_id_from_request_body(request_body) == expected_end_user_id
|
||||
|
||||
|
||||
_BUCKET_ALLOWLIST: Final = {"allowed_client_side_s3_buckets": ["team-batch-bucket"]}
|
||||
_ENDPOINT_ALLOWLIST: Final = {"allowed_client_side_s3_endpoint_urls": ["https://s3.us-west-2.amazonaws.com"]}
|
||||
|
||||
|
||||
def _batches_body_is_safe(body: dict[str, object], general_settings: dict[str, object]) -> bool:
|
||||
return is_request_body_safe(
|
||||
request_body=body,
|
||||
general_settings=general_settings,
|
||||
llm_router=None,
|
||||
model="",
|
||||
route="/v1/batches",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("s3_bucket_name", "team-batch-bucket"),
|
||||
("s3_output_bucket_name", "team-batch-bucket"),
|
||||
("allow_legacy_cloud_file_ids", True),
|
||||
("s3_endpoint_url", "https://s3.us-west-2.amazonaws.com"),
|
||||
],
|
||||
)
|
||||
def test_storage_setting_in_request_body_is_rejected_without_an_allowlist(field: str, value: object) -> None:
|
||||
with pytest.raises(ValueError, match="Rejected Request") as error:
|
||||
_batches_body_is_safe({field: value}, {})
|
||||
|
||||
assert field in str(error.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["s3_bucket_name", "s3_output_bucket_name"])
|
||||
def test_bucket_on_the_proxy_allowlist_is_accepted(field: str) -> None:
|
||||
assert _batches_body_is_safe({field: "team-batch-bucket"}, _BUCKET_ALLOWLIST) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["s3_bucket_name", "s3_output_bucket_name"])
|
||||
def test_bucket_off_the_proxy_allowlist_is_rejected_and_names_the_setting(field: str) -> None:
|
||||
with pytest.raises(ValueError, match=field) as error:
|
||||
_batches_body_is_safe({field: "someone-elses-bucket"}, _BUCKET_ALLOWLIST)
|
||||
|
||||
assert "general_settings.allowed_client_side_s3_buckets" in str(error.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"general_settings,accepted_body,rejected_field",
|
||||
[
|
||||
(
|
||||
{**_BUCKET_ALLOWLIST, "allowed_client_side_s3_endpoint_urls": None},
|
||||
{"s3_bucket_name": "team-batch-bucket"},
|
||||
"s3_endpoint_url",
|
||||
),
|
||||
(
|
||||
{**_ENDPOINT_ALLOWLIST, "allowed_client_side_s3_buckets": None},
|
||||
{"s3_endpoint_url": "https://s3.us-west-2.amazonaws.com"},
|
||||
"s3_bucket_name",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_allowlist_left_null_in_config_is_treated_as_empty(
|
||||
general_settings: dict[str, object], accepted_body: dict[str, object], rejected_field: str
|
||||
) -> None:
|
||||
assert _batches_body_is_safe(accepted_body, general_settings) is True
|
||||
with pytest.raises(ValueError, match=f"{rejected_field} is not allowed"):
|
||||
_batches_body_is_safe({rejected_field: "team-batch-bucket"}, general_settings)
|
||||
|
||||
|
||||
def test_endpoint_on_the_proxy_allowlist_is_accepted() -> None:
|
||||
assert _batches_body_is_safe({"s3_endpoint_url": "https://s3.us-west-2.amazonaws.com"}, _ENDPOINT_ALLOWLIST) is True
|
||||
|
||||
|
||||
def test_endpoint_off_the_proxy_allowlist_is_rejected_and_names_the_setting() -> None:
|
||||
with pytest.raises(ValueError, match="s3_endpoint_url") as error:
|
||||
_batches_body_is_safe({"s3_endpoint_url": "https://attacker.example"}, _ENDPOINT_ALLOWLIST)
|
||||
|
||||
assert "general_settings.allowed_client_side_s3_endpoint_urls" in str(error.value)
|
||||
|
||||
|
||||
def test_bucket_allowlist_admits_neither_an_endpoint_nor_the_legacy_file_flag() -> None:
|
||||
with pytest.raises(ValueError, match="s3_endpoint_url"):
|
||||
_batches_body_is_safe(
|
||||
{"s3_bucket_name": "team-batch-bucket", "s3_endpoint_url": "team-batch-bucket"},
|
||||
_BUCKET_ALLOWLIST,
|
||||
)
|
||||
with pytest.raises(ValueError, match="allow_legacy_cloud_file_ids"):
|
||||
_batches_body_is_safe(
|
||||
{"s3_bucket_name": "team-batch-bucket", "allow_legacy_cloud_file_ids": True},
|
||||
_BUCKET_ALLOWLIST,
|
||||
)
|
||||
|
||||
|
||||
def test_endpoint_allowlist_does_not_admit_a_bucket() -> None:
|
||||
with pytest.raises(ValueError, match="s3_bucket_name"):
|
||||
_batches_body_is_safe({"s3_bucket_name": "https://s3.us-west-2.amazonaws.com"}, _ENDPOINT_ALLOWLIST)
|
||||
|
||||
|
||||
def test_bucket_nested_in_extra_body_is_checked_against_the_allowlist() -> None:
|
||||
assert _batches_body_is_safe({"extra_body": {"s3_bucket_name": "team-batch-bucket"}}, _BUCKET_ALLOWLIST) is True
|
||||
with pytest.raises(ValueError, match="s3_bucket_name"):
|
||||
_batches_body_is_safe({"extra_body": {"s3_bucket_name": "someone-elses-bucket"}}, _BUCKET_ALLOWLIST)
|
||||
|
||||
|
||||
def test_proxy_wide_client_side_credentials_still_admit_any_bucket() -> None:
|
||||
assert (
|
||||
_batches_body_is_safe(
|
||||
{"s3_bucket_name": "any-bucket", "allow_legacy_cloud_file_ids": True},
|
||||
{"allow_client_side_credentials": True},
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_other_blocked_fields_do_not_point_at_the_storage_allowlist() -> None:
|
||||
with pytest.raises(ValueError, match="api_base") as error:
|
||||
_batches_body_is_safe({"api_base": "https://attacker.example"}, _BUCKET_ALLOWLIST)
|
||||
|
||||
assert "allowed_client_side" not in str(error.value)
|
||||
|
||||
|
||||
def _request_with_content_length(content_length: int) -> Request:
|
||||
return Request(
|
||||
{
|
||||
|
|
|
|||
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -29796,6 +29796,16 @@ export interface components {
|
|||
* @description If True, lets keys address Responses API ids that this proxy did not issue (raw provider ids, or ids issued before response-id encryption was configured). Such an id carries no owner, so no ownership check can run on it; ids this proxy did issue keep full ownership enforcement. Off by default, in which case an unrecognized response id is rejected with 403
|
||||
*/
|
||||
allow_unmanaged_response_ids?: boolean | null;
|
||||
/**
|
||||
* Allowed Client Side S3 Buckets
|
||||
* @description S3 bucket names a client may send as `s3_bucket_name` or `s3_output_bucket_name` in a request body, e.g. on /v1/batches. Any other bucket is rejected unless client-side credentials are allowed
|
||||
*/
|
||||
allowed_client_side_s3_buckets?: string[] | null;
|
||||
/**
|
||||
* Allowed Client Side S3 Endpoint Urls
|
||||
* @description Exact `s3_endpoint_url` values a client may send in a request body. Any other value is rejected unless client-side credentials are allowed
|
||||
*/
|
||||
allowed_client_side_s3_endpoint_urls?: string[] | null;
|
||||
/**
|
||||
* Allowed File Extensions
|
||||
* @description the only file extensions (e.g. ['.jsonl', '.pdf', '.txt']) accepted on /v1/files uploads, for any purpose, matched case-insensitively against the uploaded filename. Files with any other extension, or none, are rejected. An empty list rejects every upload. Unset means no allowlist is applied
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue