diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 17f87a0cbcb..d71de181977 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -184,24 +184,34 @@ def is_request_body_safe( "aws_web_identity_token", "aws_role_name", "vertex_credentials", - # Endpoint-targeting fields that must not flow through clientside. - # ``aws_bedrock_runtime_endpoint`` redirects Bedrock traffic to an - # arbitrary host; ``langsmith_base_url`` / ``langfuse_host`` are - # callback hostnames that, if attacker-controlled, exfiltrate the - # request payload (incl. message content + admin-configured - # observability keys) to the attacker's server. + # Endpoint-targeting fields that retarget the outbound request or + # an observability callback. An attacker-controlled value either + # exfiltrates the request payload (incl. messages + admin-set + # tokens) to the attacker's host, or coerces the proxy into + # authenticating against the attacker's host with admin secrets. "aws_bedrock_runtime_endpoint", "langsmith_base_url", "langfuse_host", + "posthog_host", + "braintrust_host", + "slack_webhook_url", ] + # The blocklist is enforced unconditionally. Legitimate clientside + # credential / endpoint passthrough goes through one of the two + # explicit admin opt-ins (``general_settings.allow_client_side_credentials`` + # proxy-wide or ``configurable_clientside_auth_params`` per deployment). + # Historically there was a third, *implicit*, *caller-controlled* path: + # ``check_complete_credentials`` returned True when the caller supplied + # any non-empty ``api_key``, which made the entire blocklist a no-op. + # That bypass turned every missing entry on the blocklist into an + # exploitable SSRF / credential-exfil hole — see GHSA-jh89-88fc-qrfp, + # GHSA-3frq-6r6h-7j64, and the chain of veria-admin findings (Dv_m860l, + # b_yRJeQ5, stN90yjP, LBlyOAc8, U2TD78kg). Removed: the blocklist now + # has a single, predictable failure mode for missing entries (a 400), + # not a credential leak. for param in banned_params: - if ( - param in request_body - and not check_complete_credentials( # allow client-credentials to be passed to proxy - request_body=request_body - ) - ): + if param in request_body: if general_settings.get("allow_client_side_credentials") is True: return True elif ( @@ -216,7 +226,10 @@ def is_request_body_safe( return True raise ValueError( f"Rejected Request: {param} is not allowed in request body. " - "Enable with `general_settings::allow_client_side_credentials` on proxy config.yaml. " + "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", ) diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index a373d797e79..222787e9ea2 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -909,6 +909,9 @@ class TestIsRequestBodySafeBlocksEndpointTargetingFields: "aws_bedrock_runtime_endpoint", "langsmith_base_url", "langfuse_host", + "posthog_host", + "braintrust_host", + "slack_webhook_url", ], ) def test_endpoint_targeting_field_in_request_body_is_rejected(self, field): @@ -921,3 +924,40 @@ class TestIsRequestBodySafeBlocksEndpointTargetingFields: ) # The function lists the offending param name in the error. assert field in str(exc.value) + + @pytest.mark.parametrize( + "field", + ["api_base", "base_url", "user_config", "langfuse_host", "slack_webhook_url"], + ) + def test_api_key_does_not_bypass_blocklist(self, field): + # Regression: the historical ``check_complete_credentials`` clause + # made the entire blocklist a no-op for any caller that supplied + # a non-empty ``api_key``. That bypass turned every missing entry + # on the blocklist into an SSRF / credential-exfil hole. Verify + # that supplying an api_key (alongside the banned param) does NOT + # bypass the gate — it can only be opened by an admin opt-in. + with pytest.raises(ValueError) as exc: + is_request_body_safe( + request_body={ + "model": "gpt-4", + "api_key": "sk-anything", + field: "https://attacker.example", + }, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + assert field in str(exc.value) + + def test_admin_opt_in_proxy_wide_still_allows(self): + # ``general_settings.allow_client_side_credentials = True`` remains + # the documented proxy-wide BYOK opt-in. + assert ( + is_request_body_safe( + request_body={"model": "gpt-4", "api_base": "https://my-byok.example"}, + general_settings={"allow_client_side_credentials": True}, + llm_router=None, + model="gpt-4", + ) + is True + )