diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 2b01e02bb06..6b5565252cf 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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.", diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 25f900a5f9f..d304507f153 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -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, ...]] = ( diff --git a/tests/unit/proxy/auth/test_auth_utils.py b/tests/unit/proxy/auth/test_auth_utils.py index ee1ae43fd3a..20c9d02301f 100644 --- a/tests/unit/proxy/auth/test_auth_utils.py +++ b/tests/unit/proxy/auth/test_auth_utils.py @@ -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( { diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 262e8b6deae..35804d1e8fa 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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