This commit is contained in:
ryan-crabbe-berri 2026-10-25 14:48:16 +00:00 • committed by GitHub
commit 80456cc71e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 188 additions and 10 deletions

View file

@ -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.",

View file

@ -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, ...]] = (

View file

@ -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(
{

View file

@ -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