diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 75e3baf2c4a..99104040831 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -1747,31 +1747,49 @@ _HEADERS_NEVER_FORWARDED_TO_VERTEX: Final = frozenset( ) +def _credentialless_caller_key_values(request: Request) -> frozenset[str]: + """Every header value the proxy would accept as this caller's LiteLLM key. + + Beyond the built-in ``x-litellm-api-key`` / ``Authorization`` that + ``get_litellm_virtual_key`` reads, ``user_api_key_auth`` also authenticates a + caller from the operator-configured ``general_settings.litellm_key_header_name`` + when one is set, reading that header straight off the request. Any of those + values equals the virtual key and must never be forwarded to Google. + """ + from litellm.proxy.proxy_server import general_settings + + custom_key_header_name: Final = general_settings.get("litellm_key_header_name") or "" + candidates: Final = ( + get_litellm_virtual_key(request), + request.headers.get(custom_key_header_name, "") if custom_key_header_name else "", + ) + return frozenset(_bearer_stripped(value) for value in candidates if _bearer_stripped(value)) + + def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) -> Mapping[str, str]: """ Header set to forward on the bring-your-own-credentials Vertex passthrough branch, used when the proxy has no Vertex credential configured. No credential the proxy accepts for caller authentication is forwarded to - Google. LiteLLM reads the caller's virtual key from ``x-litellm-api-key``, - ``api-key``, ``x-api-key``, ``Authorization``, and ``x-goog-api-key``. Vertex - only ever authenticates with an OAuth token in ``Authorization`` or an API key - in ``x-goog-api-key``, so ``x-litellm-api-key`` / ``api-key`` / ``x-api-key`` - can only carry caller auth material and are dropped by name. ``Authorization`` - and ``x-goog-api-key`` may instead carry a genuine bring-your-own Google - credential, so they are kept unless their value is the caller's virtual key, - which is dropped by value (normalizing any ``Bearer`` prefix). When neither a - surviving ``Authorization`` nor ``x-goog-api-key`` remains the request is - rejected so the virtual key cannot leak upstream. + Google. Vertex only ever authenticates with an OAuth token in ``Authorization`` + or an API key in ``x-goog-api-key``, so the proxy-only auth headers Google never + consumes (``x-litellm-api-key`` / ``api-key`` / ``x-api-key``) are dropped by + name. ``Authorization`` and ``x-goog-api-key`` may instead carry a genuine + bring-your-own Google credential, so they are kept unless their value is one of + the caller's LiteLLM key values, which are dropped by value (normalizing any + ``Bearer`` prefix). Dropping by value also covers a virtual key sent in the + operator-configured ``litellm_key_header_name``, whatever that header is named. + When neither a surviving ``Authorization`` nor ``x-goog-api-key`` remains the + request is rejected so the virtual key cannot leak upstream. """ incoming: Final = _safe_get_request_headers(request) - caller_virtual_key: Final = _bearer_stripped(get_litellm_virtual_key(request)) + caller_key_values: Final = _credentialless_caller_key_values(request) forwarded: Final = MappingProxyType( { name: value for name, value in incoming.items() - if name not in _HEADERS_NEVER_FORWARDED_TO_VERTEX - and not (caller_virtual_key and _bearer_stripped(value) == caller_virtual_key) + if name not in _HEADERS_NEVER_FORWARDED_TO_VERTEX and _bearer_stripped(value) not in caller_key_values } ) if "authorization" not in forwarded and "x-goog-api-key" not in forwarded: diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index e268c8cd2b9..c5e56788a96 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -3461,7 +3461,9 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: proxy-only auth headers Google never consumes (``x-litellm-api-key``, ``api-key``, ``x-api-key``) are dropped by name, and the virtual key is dropped by value from ``Authorization`` / ``x-goog-api-key``, which may instead carry a - genuine bring-your-own Google credential that must still pass through. + genuine bring-your-own Google credential that must still pass through. The + by-value strip also covers a virtual key sent in the operator-configured + ``general_settings.litellm_key_header_name``, whatever that header is named. """ VKEY = "sk-litellm-victim-key" @@ -3606,6 +3608,42 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: assert "azure-style-caller-secret" not in forwarded_blob assert "anthropic-style-caller-secret" not in forwarded_blob + @pytest.mark.asyncio + async def test_virtual_key_in_operator_configured_header_is_stripped(self, monkeypatch): + with mock.patch.dict( # test-quality-ok: general_settings is the real proxy config surface for litellm_key_header_name; no injection seam exists on this route + "litellm.proxy.proxy_server.general_settings", + {"litellm_key_header_name": "x-company-key"}, + ): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"x-company-key", f"Bearer {self.VKEY}".encode()), + (b"x-goog-api-key", b"AIza-real-google-api-key"), + (b"content-type", b"application/json"), + ], + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("x-goog-api-key") == "AIza-real-google-api-key" + assert "x-company-key" not in forwarded + assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items()) + + @pytest.mark.asyncio + async def test_virtual_key_in_operator_configured_header_alone_is_rejected(self, monkeypatch): + with mock.patch.dict( # test-quality-ok: general_settings is the real proxy config surface for litellm_key_header_name; no injection seam exists on this route + "litellm.proxy.proxy_server.general_settings", + {"litellm_key_header_name": "x-company-key"}, + ): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"x-company-key", f"Bearer {self.VKEY}".encode()), + (b"content-type", b"application/json"), + ], + ) + assert forwarded is None, "a virtual key in the custom auth header must not satisfy the gate nor be forwarded" + assert raised is not None and raised.status_code == 401 + class TestGetAzureAISearchIndexFromEndpoint: """The operable index is only the segment right after ``indexes``.