From 0a4c8d7e217fc8701ef807702796a014d6f6b72e Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 19 Oct 2026 21:38:05 -0700 Subject: [PATCH 1/3] feat(proxy): let admins allow specific S3 buckets and endpoints in request bodies, block bucket settings otherwise Client requests can no longer pick s3_bucket_name, s3_output_bucket_name or allow_legacy_cloud_file_ids unless the value is on general_settings.allowed_client_side_s3_buckets (buckets) or the admin opted in the usual way. s3_endpoint_url, already blocked, can now be allowed per value through general_settings.allowed_client_side_s3_endpoint_urls --- litellm/proxy/_types.py | 8 ++ litellm/proxy/auth/auth_utils.py | 58 +++++++++-- tests/unit/proxy/auth/test_auth_utils.py | 95 +++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 10 ++ 4 files changed, 162 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index e4f77ac0005..1887b86bd71 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2738,6 +2738,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 7a05121ff2b..378de8cfa3e 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -9,7 +9,7 @@ from types import MappingProxyType from typing import Any, Final, Protocol 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 @@ -435,6 +435,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 @@ -470,6 +473,46 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( ) +_PROXY_ALLOWLIST_SETTING_BY_PARAM: Final = 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, ...] = () + allowed_client_side_s3_endpoint_urls: tuple[str, ...] = () + + def admits(self, param: str, value: object) -> bool: + match param: + case "s3_bucket_name" | "s3_output_bucket_name": + return value in self.allowed_client_side_s3_buckets + case "s3_endpoint_url": + return value in self.allowed_client_side_s3_endpoint_urls + case _: + return False + + +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, @@ -499,6 +542,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 param in _PROXY_ALLOWLIST_SETTING_BY_PARAM and _ClientSideStorageAllowlist.model_validate( + general_settings + ).admits(param, body[param]): + continue if ( _allow_model_level_clientside_configurable_parameters( model=model, @@ -514,14 +561,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 02be27d921e..75a408dd61c 100644 --- a/tests/unit/proxy/auth/test_auth_utils.py +++ b/tests/unit/proxy/auth/test_auth_utils.py @@ -5033,3 +5033,98 @@ def test_get_end_user_id_from_request_body_backwards_compatibility(): ) for request_body, expected_end_user_id in cases: 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) + + +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) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index ac096862b56..ec72dd71e7f 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -29744,6 +29744,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 From c0ff9cb54d0290ab78d04e3c208a527e7b81f662 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 20 Oct 2026 10:35:37 -0700 Subject: [PATCH 2/3] refactor(proxy): drive the client-side S3 allowlist from one typed field-to-setting map --- litellm/proxy/auth/auth_utils.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 378de8cfa3e..c84cc7d6231 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -6,7 +6,7 @@ from collections.abc import Collection, Iterator, Mapping from functools import lru_cache 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 BaseModel, ConfigDict, PositiveInt, TypeAdapter, ValidationError @@ -473,7 +473,11 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( ) -_PROXY_ALLOWLIST_SETTING_BY_PARAM: Final = MappingProxyType( +_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", @@ -488,14 +492,12 @@ class _ClientSideStorageAllowlist(BaseModel): allowed_client_side_s3_buckets: tuple[str, ...] = () allowed_client_side_s3_endpoint_urls: tuple[str, ...] = () - def admits(self, param: str, value: object) -> bool: - match param: - case "s3_bucket_name" | "s3_output_bucket_name": + 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 - case "s3_endpoint_url": + case "allowed_client_side_s3_endpoint_urls": return value in self.allowed_client_side_s3_endpoint_urls - case _: - return False def _banned_param_error(param: str) -> ValueError: @@ -542,9 +544,9 @@ 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 param in _PROXY_ALLOWLIST_SETTING_BY_PARAM and _ClientSideStorageAllowlist.model_validate( - general_settings - ).admits(param, body[param]): + 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( From 943f0b8b85b71c1e71f359ef8c9b5f218d274150 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 20 Oct 2026 10:43:03 -0700 Subject: [PATCH 3/3] fix(proxy): treat a null client-side S3 allowlist in config as empty instead of failing the request --- litellm/proxy/auth/auth_utils.py | 8 ++++---- tests/unit/proxy/auth/test_auth_utils.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index c84cc7d6231..b23eb9580c7 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -489,15 +489,15 @@ _PROXY_ALLOWLIST_SETTING_BY_PARAM: Final[Mapping[str, _ClientSideAllowlistSettin class _ClientSideStorageAllowlist(BaseModel): model_config = ConfigDict(frozen=True) - allowed_client_side_s3_buckets: tuple[str, ...] = () - allowed_client_side_s3_endpoint_urls: tuple[str, ...] = () + 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 + 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 + return value in (self.allowed_client_side_s3_endpoint_urls or ()) def _banned_param_error(param: str) -> ValueError: diff --git a/tests/unit/proxy/auth/test_auth_utils.py b/tests/unit/proxy/auth/test_auth_utils.py index 75a408dd61c..394a111a26e 100644 --- a/tests/unit/proxy/auth/test_auth_utils.py +++ b/tests/unit/proxy/auth/test_auth_utils.py @@ -5078,6 +5078,29 @@ def test_bucket_off_the_proxy_allowlist_is_rejected_and_names_the_setting(field: 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