Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions litellm/proxy/auth/auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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(
Expand Down Expand Up @@ -276,6 +277,7 @@ def _build_banned_observability_params() -> FrozenSet[str]:
# integrations are covered automatically. Sorted for stable iteration
# order and reviewable diffs.
*sorted(_build_banned_observability_params()),
*sorted(CustomPricingLiteLLMParams.model_fields.keys()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: Pricing overrides can be bypassed by the credentials opt-in

Adding custom pricing fields to _BANNED_REQUEST_BODY_PARAMS makes them subject to the existing allow_client_side_credentials early return in _check_banned_params. On deployments that enable client-side credentials, an authenticated client can send input_cost_per_token and output_cost_per_token in a completion request and update the shared model cost entry, letting them undercount or zero out spend for that model. Keep custom pricing in a separate deny list that is not bypassed by allow_client_side_credentials, or require a pricing-specific admin opt-in.

)
Comment on lines 279 to 281

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Pricing-injection bypass via allow_client_side_credentials

_check_banned_params returns early for every banned param when general_settings["allow_client_side_credentials"] is True, so the new CustomPricingLiteLLMParams entries are also fully bypassed. The allow_client_side_credentials flag was designed to allow trusted clients to supply their own API keys and endpoint overrides; it does not logically opt the admin into allowing clients to overwrite the global litellm.model_cost registry (which affects cost tracking for all users on the instance). An admin who enables credential passthrough for a multi-tenant deployment would unwittingly open global pricing writes to every authenticated caller. Consider checking the pricing fields with a separate, unconditional guard that is not gated on allow_client_side_credentials.



Expand Down
11 changes: 11 additions & 0 deletions litellm/proxy/rag_endpoints/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
_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,
)
Expand Down Expand Up @@ -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(
Expand Down
59 changes: 59 additions & 0 deletions tests/test_litellm/proxy/auth/test_auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
63 changes: 63 additions & 0 deletions tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()}"
)
Comment on lines +191 to +193

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Weak happy-path assertion may hide 5xx errors

assert response.status_code != 400 passes even if the endpoint returns a 500 (e.g., if the aingest mock is not applied or the test client has auth issues). This means the test would silently "pass" on a server error rather than confirming the clean payload was actually accepted. Asserting response.status_code == 200 (or the expected 2xx code) would catch regressions properly.

Loading