fix(vertex-passthrough): cover operator-configured pass-through key headers

user_api_key_auth also accepts the caller key from a pass_through_endpoints
entry's headers.litellm_user_api_key, not just litellm_key_header_name. Drop
every operator-configured caller-key header by name and treat them as
top-precedence caller-key sources, so a virtual key sent through one is never
forwarded to Google.
This commit is contained in:
mateo-berri 2026-08-24 13:47:42 -07:00
parent a8f24c8568
commit 2fe1e7e43f
2 changed files with 67 additions and 25 deletions

View file

@ -1769,26 +1769,46 @@ _VERTEX_CALLER_KEY_HEADER_PRECEDENCE: Final = (
)
def _operator_configured_caller_key_header_names() -> tuple[str, ...]:
"""Lowercased header names the operator has configured as caller-key sources.
``user_api_key_auth`` accepts the caller's key from two runtime-configured
headers beyond the built-in ones: ``general_settings.litellm_key_header_name``,
and each ``general_settings.pass_through_endpoints`` entry's
``headers.litellm_user_api_key``. Google never consumes either, so they are
both dropped by name and treated as top-precedence caller-key sources.
"""
from litellm.proxy.proxy_server import general_settings
custom_key_header: Final = general_settings.get("litellm_key_header_name")
pass_through_endpoints: Final = general_settings.get("pass_through_endpoints")
endpoints: Final = pass_through_endpoints if isinstance(pass_through_endpoints, list) else ()
pass_through_key_headers: Final = tuple(
headers["litellm_user_api_key"]
for endpoint in endpoints
if isinstance(endpoint, dict)
for headers in (endpoint.get("headers"),)
if isinstance(headers, dict) and isinstance(headers.get("litellm_user_api_key"), str)
)
configured: Final = ((custom_key_header,) if isinstance(custom_key_header, str) else ()) + pass_through_key_headers
return tuple(dict.fromkeys(name.lower() for name in configured))
def _authenticated_caller_key_values(request: Request) -> frozenset[str]:
"""The value ``user_api_key_auth`` would accept as this caller's LiteLLM key.
The Vertex route authenticates through ``Depends(user_api_key_auth)``, which
resolves the key from the first present of the credential headers in
``get_api_key``'s precedence order, with the operator-configured
``general_settings.litellm_key_header_name`` overriding all of them. Some of
those headers (``Authorization``, ``x-goog-api-key``) are also kept as genuine
bring-your-own Google credentials, so returning only the value that actually
authenticated lets the filter strip that value wherever it appears while
leaving a real Google credential in place. An empty set means no caller key
was found, so nothing is value-stripped.
``litellm_key_header_name`` / ``pass_through_endpoints`` headers overriding all
of them. Some of those headers (``Authorization``, ``x-goog-api-key``) are also
kept as genuine bring-your-own Google credentials, so returning only the value
that actually authenticated lets the filter strip that value wherever it
appears while leaving a real Google credential in place. An empty set means no
caller key was found, so nothing is value-stripped.
"""
from litellm.proxy.proxy_server import general_settings
incoming: Final = _safe_get_request_headers(request)
custom_key_header_name: Final = (general_settings.get("litellm_key_header_name") or "").lower()
ordered_names: Final = (
(custom_key_header_name,) if custom_key_header_name else ()
) + _VERTEX_CALLER_KEY_HEADER_PRECEDENCE
ordered_names: Final = _operator_configured_caller_key_header_names() + _VERTEX_CALLER_KEY_HEADER_PRECEDENCE
present_values: Final = (incoming[name] for name in ordered_names if incoming.get(name))
authenticated_key: Final = next(
(stripped for value in present_values if (stripped := _normalize_credential_value(value))),
@ -1808,26 +1828,28 @@ def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) -
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
(everything in that set except those two, e.g. ``x-litellm-api-key`` /
``api-key`` / ``x-api-key`` / ``Ocp-Apim-Subscription-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 the
caller's authenticated LiteLLM key, which is dropped by value (normalizing any
``Bearer`` / ``Basic`` / ``AWS4`` auth-scheme prefix the same way authentication does). Because the value that authenticated is resolved by the
same precedence ``user_api_key_auth`` uses, a virtual key sent only in
``x-goog-api-key`` (or in the operator-configured ``litellm_key_header_name``)
is dropped too, while a real Google key in ``x-goog-api-key`` alongside a
virtual key in a higher-precedence header is preserved. When neither a
surviving ``Authorization`` nor ``x-goog-api-key`` remains the request is
rejected so the virtual key cannot leak upstream.
``api-key`` / ``x-api-key`` / ``Ocp-Apim-Subscription-Key``, plus any
operator-configured ``litellm_key_header_name`` / ``pass_through_endpoints``
key header) 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 authenticated LiteLLM key, which is dropped
by value (normalizing any ``Bearer`` / ``Basic`` / ``AWS4`` auth-scheme prefix
the same way authentication does). Because the value that authenticated is
resolved by the same precedence ``user_api_key_auth`` uses, a virtual key sent
only in ``x-goog-api-key`` (or in an operator-configured key header) is dropped
too, while a real Google key in ``x-goog-api-key`` alongside a virtual key in a
higher-precedence header is preserved. 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_key_values: Final = _authenticated_caller_key_values(request)
never_forwarded: Final = _HEADERS_NEVER_FORWARDED_TO_VERTEX.union(_operator_configured_caller_key_header_names())
forwarded: Final = MappingProxyType(
{
name: value
for name, value in incoming.items()
if name not in _HEADERS_NEVER_FORWARDED_TO_VERTEX
and _normalize_credential_value(value) not in caller_key_values
if name not in never_forwarded and _normalize_credential_value(value) not in caller_key_values
}
)
if "authorization" not in forwarded and "x-goog-api-key" not in forwarded:

View file

@ -3698,6 +3698,26 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak:
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
@pytest.mark.asyncio
async def test_virtual_key_in_pass_through_configured_header_is_stripped(self, monkeypatch):
with mock.patch.dict( # test-quality-ok: general_settings is the real proxy config surface for pass_through_endpoints; no injection seam exists on this route
"litellm.proxy.proxy_server.general_settings",
{"pass_through_endpoints": [{"headers": {"litellm_user_api_key": "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())
class TestGetAzureAISearchIndexFromEndpoint:
"""The operable index is only the segment right after ``indexes``.