From fdb55ab65b9e46a36ebd1bd6d46be33ea26f86c8 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 13 May 2026 17:05:36 -0700 Subject: [PATCH 1/2] fix: block client-side pricing injection via request body Authenticated clients could supply CustomPricingLiteLLMParams fields (input_cost_per_token, output_cost_per_token, etc.) in the request body. These were forwarded to register_model() in main.py, permanently mutating the shared global litellm.model_cost dict for all users on the instance. Adds all CustomPricingLiteLLMParams fields to _BANNED_REQUEST_BODY_PARAMS so is_request_body_safe() rejects them before they reach completion(). New pricing fields added to CustomPricingLiteLLMParams are auto-covered. Admin opt-in via allow_client_side_credentials or configurable_clientside_auth_params still works as before. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- litellm/proxy/auth/auth_utils.py | 2 + .../proxy/auth/test_auth_utils.py | 59 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 8012a9be768..29f830fbb43 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -13,6 +13,7 @@ from litellm.constants import STANDARD_CUSTOMER_ID_HEADERS from litellm.litellm_core_utils.url_utils import SSRFError, validate_url from litellm.proxy._types import * from litellm.types.router import CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS +from litellm.types.utils import CustomPricingLiteLLMParams def _get_request_ip_address( @@ -276,6 +277,7 @@ _BANNED_REQUEST_BODY_PARAMS: Tuple[str, ...] = ( # integrations are covered automatically. Sorted for stable iteration # order and reviewable diffs. *sorted(_build_banned_observability_params()), + *sorted(CustomPricingLiteLLMParams.model_fields.keys()), ) diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index d1a9d6e8934..70e8812c99a 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -1514,3 +1514,62 @@ def test_observability_ban_covers_canonical_supported_callback_params(): f"{param} is in _request_blocked_callback_params but is not banned " "at the proxy request-body boundary." ) + + +# ── pricing injection (global model cost registry poisoning) ────────────────── + + +class TestPricingInjectionBlocked: + """Authenticated clients must not be able to mutate the global + litellm.model_cost registry by supplying pricing fields in the request + body. Any CustomPricingLiteLLMParams field (input_cost_per_token etc.) + passed to completion() is forwarded to register_model(), which overwrites + the shared global dict for ALL users on the instance. + + Fix: all CustomPricingLiteLLMParams fields are in _BANNED_REQUEST_BODY_PARAMS, + so is_request_body_safe() rejects them before they reach completion(). + """ + + @pytest.mark.parametrize( + "field,value", + [ + ("input_cost_per_token", -0.01), + ("output_cost_per_token", 0.0), + ("input_cost_per_second", 999.0), + ("output_cost_per_second", -1.0), + ("cache_read_input_token_cost", 0.0), + ("cache_creation_input_token_cost", -0.05), + ], + ) + def test_pricing_field_rejected_by_default(self, field, value): + with pytest.raises(ValueError) as exc: + is_request_body_safe( + request_body={"model": "gpt-4", field: value}, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + assert field in str(exc.value) + + def test_all_custom_pricing_fields_are_banned(self): + from litellm.proxy.auth.auth_utils import _BANNED_REQUEST_BODY_PARAMS + from litellm.types.utils import CustomPricingLiteLLMParams + + banned = set(_BANNED_REQUEST_BODY_PARAMS) + for field in CustomPricingLiteLLMParams.model_fields: + assert field in banned, ( + f"CustomPricingLiteLLMParams.{field} is not in " + "_BANNED_REQUEST_BODY_PARAMS — clients can poison the global " + "model cost registry by supplying it in the request body." + ) + + def test_pricing_field_allowed_with_admin_opt_in(self): + assert ( + is_request_body_safe( + request_body={"model": "gpt-4", "input_cost_per_token": 0.00001}, + general_settings={"allow_client_side_credentials": True}, + llm_router=None, + model="gpt-4", + ) + is True + ) From b2c93b15e7d0048032c25dd1e9e0a22851b6618b Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 13 May 2026 18:10:04 -0700 Subject: [PATCH 2/2] fix: block SSRF fields in RAG ingest vector_store config aws_sts_endpoint, aws_web_identity_token, and aws_bedrock_runtime_endpoint in ingest_options.vector_store were passed directly to the Bedrock ingestion class, which reads them into boto3 STS client construction. Any authenticated caller could redirect AssumeRole calls to an attacker-controlled server, leaking the proxy's instance profile credentials. Calls is_request_body_safe() on ingest_options["vector_store"] before forwarding to litellm.aingest(). Same banned-params list and admin opt-in escape hatch (allow_client_side_credentials) as the /chat/completions path. ValueError from the safety check is caught and re-raised as HTTP 400. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- litellm/proxy/rag_endpoints/endpoints.py | 11 ++++ .../proxy/rag_endpoints/test_rag_endpoints.py | 63 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/litellm/proxy/rag_endpoints/endpoints.py b/litellm/proxy/rag_endpoints/endpoints.py index 498d77f7535..2e53301c0d2 100644 --- a/litellm/proxy/rag_endpoints/endpoints.py +++ b/litellm/proxy/rag_endpoints/endpoints.py @@ -23,6 +23,7 @@ from litellm.proxy.common_utils.http_parsing_utils import ( _safe_get_request_headers, get_form_data, ) +from litellm.proxy.auth.auth_utils import is_request_body_safe from litellm.proxy.vector_store_endpoints.utils import ( assert_user_can_access_vector_store_id, ) @@ -469,6 +470,16 @@ async def rag_ingest( user_api_key_dict=user_api_key_dict, ) + try: + is_request_body_safe( + request_body=ingest_options.get("vector_store", {}), + general_settings=general_settings, + llm_router=llm_router, + model="", + ) + except ValueError as e: + raise HTTPException(status_code=400, detail={"error": str(e)}) + # Add litellm data request_data: Dict[str, Any] = {} request_data = await add_litellm_data_to_request( diff --git a/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py b/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py index 945afd886cb..3280b01ea3d 100644 --- a/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py +++ b/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py @@ -128,3 +128,66 @@ def test_internal_user_rag_ingest_without_vector_store_id_allowed(client_interna f"internal_user should be allowed to create new vector stores. " f"Response: {response.json()}" ) + + +class TestRagIngestSSRFBlocked: + """ + aws_sts_endpoint and related credential-redirect fields must be rejected + in ingest_options.vector_store. Without this guard, any authenticated + client can coerce the proxy to make a signed STS AssumeRole call to an + attacker-controlled server, leaking the instance profile credentials. + """ + + @pytest.mark.parametrize( + "field,value", + [ + ("aws_sts_endpoint", "https://attacker.example/sts"), + ("aws_web_identity_token", "fake-token"), + ("aws_bedrock_runtime_endpoint", "https://attacker.example/bedrock"), + ], + ) + def test_ssrf_field_in_vector_store_config_rejected( + self, field, value, client_internal_user + ): + payload = { + "file_url": "https://example.com/doc.pdf", + "ingest_options": { + "vector_store": { + "custom_llm_provider": "bedrock", + field: value, + } + }, + } + response = client_internal_user.post( + "/v1/rag/ingest", + json=payload, + ) + assert response.status_code == 400, ( + f"{field} in ingest_options.vector_store should be rejected (400), " + f"got {response.status_code}: {response.json()}" + ) + body = response.json() + detail = body.get("detail", {}) + error_text = ( + detail.get("error", "") if isinstance(detail, dict) else str(detail) + ) + assert field in error_text, f"Error should name the offending field: {error_text}" + + def test_clean_bedrock_ingest_options_not_rejected(self, client_internal_user): + with patch( + "litellm.proxy.rag_endpoints.endpoints.litellm.aingest", + new_callable=AsyncMock, + return_value={"vector_store_id": "vs_bedrock", "file_id": "file_123"}, + ): + response = client_internal_user.post( + "/v1/rag/ingest", + json={ + "file_url": "https://example.com/doc.pdf", + "ingest_options": { + "vector_store": {"custom_llm_provider": "bedrock"} + }, + }, + ) + assert response.status_code != 400, ( + f"Clean Bedrock ingest_options should not be rejected: {response.json()}" + )