fix(vertex-passthrough): value-strip the key by full auth precedence

The credential-less filter derived the caller key only from x-litellm-api-key,
Authorization, and the custom header, but the route authenticates through
Depends(user_api_key_auth), which also accepts the key from x-goog-api-key. A
virtual key sent only in x-goog-api-key therefore authenticated yet was kept as
a preserved upstream header and forwarded to Google. Resolve the caller key by
the same precedence get_api_key uses and value-strip exactly that, so a key in
x-goog-api-key is stripped while a real Google key alongside a higher-precedence
virtual key is preserved.
This commit is contained in:
mateo-berri 2026-08-24 13:06:13 -07:00
parent ab93636e2c
commit f3dc339e07
2 changed files with 62 additions and 23 deletions

View file

@ -1748,23 +1748,42 @@ _HEADERS_NEVER_FORWARDED_TO_VERTEX: Final = frozenset({"content-length", "host"}
)
def _credentialless_caller_key_values(request: Request) -> frozenset[str]:
"""Every header value the proxy would accept as this caller's LiteLLM key.
_VERTEX_CALLER_KEY_HEADER_PRECEDENCE: Final = (
SpecialHeaders.custom_litellm_api_key.value.lower(),
SpecialHeaders.openai_authorization.value.lower(),
SpecialHeaders.azure_authorization.value.lower(),
SpecialHeaders.anthropic_authorization.value.lower(),
SpecialHeaders.google_ai_studio_authorization.value.lower(),
SpecialHeaders.azure_apim_authorization.value.lower(),
)
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.
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.
"""
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 "",
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
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 := _bearer_stripped(value))),
"",
)
return frozenset(_bearer_stripped(value) for value in candidates if _bearer_stripped(value))
return frozenset({authenticated_key}) if authenticated_key else frozenset()
def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) -> Mapping[str, str]:
@ -1780,15 +1799,18 @@ def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) -
(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 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.
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`` prefix). 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.
"""
incoming: Final = _safe_get_request_headers(request)
caller_key_values: Final = _credentialless_caller_key_values(request)
caller_key_values: Final = _authenticated_caller_key_values(request)
forwarded: Final = MappingProxyType(
{
name: value

View file

@ -3553,6 +3553,18 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak:
assert forwarded is None, "the virtual key in x-goog-api-key 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_authenticated_solely_via_x_goog_api_key_is_rejected(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-goog-api-key", self.VKEY.encode()),
(b"content-type", b"application/json"),
],
)
assert forwarded is None, "a virtual key that authenticated via x-goog-api-key must be stripped, not forwarded"
assert raised is not None and raised.status_code == 401
@pytest.mark.asyncio
async def test_byo_google_oauth_token_still_forwards_without_virtual_key(self, monkeypatch):
raised, forwarded = await self._run(
@ -3611,12 +3623,16 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak:
@pytest.mark.asyncio
@pytest.mark.parametrize(
"credential_header",
sorted(SpecialHeaders.litellm_credential_header_names() - {"authorization", "x-goog-api-key"}),
sorted(
SpecialHeaders.litellm_credential_header_names()
- {"authorization", "x-goog-api-key", "x-litellm-api-key"}
),
)
async def test_every_non_google_credential_header_is_dropped_by_name(self, monkeypatch, credential_header):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-litellm-api-key", self.VKEY.encode()),
(b"x-goog-api-key", b"AIza-real-google-api-key"),
(credential_header.encode(), b"some-distinct-caller-secret-value"),
(b"content-type", b"application/json"),
@ -3626,9 +3642,10 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak:
assert forwarded is not None
assert forwarded.get("x-goog-api-key") == "AIza-real-google-api-key"
assert credential_header not in forwarded
assert "some-distinct-caller-secret-value" not in " ".join(
f"{name}:{value}" for name, value in forwarded.items()
)
assert "x-litellm-api-key" not in forwarded
forwarded_blob = " ".join(f"{name}:{value}" for name, value in forwarded.items())
assert self.VKEY not in forwarded_blob
assert "some-distinct-caller-secret-value" not in forwarded_blob
@pytest.mark.asyncio
async def test_virtual_key_in_operator_configured_header_is_stripped(self, monkeypatch):