fix(vertex-passthrough): normalize caller key via canonical _get_bearer_token

The filter's own Bearer-only stripping missed the other schemes
user_api_key_auth accepts, so a virtual key echoed as `Authorization: Basic
<key>` alongside a higher-precedence auth header did not match the caller key
and was forwarded to Google. Reuse the auth module's _get_bearer_token so the
comparison strips exactly what authentication does (Bearer / bearer / Basic /
AWS4-HMAC-SHA256), falling back to the raw value for a bare token.
This commit is contained in:
mateo-berri 2026-08-24 13:21:56 -07:00
parent f3dc339e07
commit 5d8286c963
2 changed files with 35 additions and 9 deletions

View file

@ -29,7 +29,11 @@ from litellm.llms.anthropic.common_utils import AnthropicModelInfo
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
from litellm.proxy._types import *
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth, user_api_key_auth_websocket
from litellm.proxy.auth.user_api_key_auth import (
_get_bearer_token,
user_api_key_auth,
user_api_key_auth_websocket,
)
from litellm.proxy.common_utils.http_parsing_utils import (
_read_request_body,
_safe_get_request_headers,
@ -1735,11 +1739,18 @@ _CREDENTIALLESS_VERTEX_MISSING_CREDENTIAL_DETAIL: Final = (
)
def _bearer_stripped(value: str) -> str:
parts: Final = value.split(None, 1)
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1]
return value
def _normalize_credential_value(value: str) -> str:
"""Reduce a header value to the bare token, matching how ``user_api_key_auth``
reads a caller's key.
Reuses the auth module's ``_get_bearer_token`` so the caller-key comparison
strips exactly the schemes authentication accepts (``Bearer`` / ``bearer`` /
``Basic`` / ``AWS4-HMAC-SHA256`` credential), rather than re-deriving a
narrower normalization here. ``_get_bearer_token`` returns ``""`` for a value
with no recognized scheme prefix, so a bare token (or a real Google
credential that carries no scheme) falls back to its own value.
"""
return _get_bearer_token(value) or value
_VERTEX_UPSTREAM_CREDENTIAL_HEADERS: Final = frozenset({"authorization", "x-goog-api-key"})
@ -1780,7 +1791,7 @@ def _authenticated_caller_key_values(request: Request) -> frozenset[str]:
) + _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))),
(stripped for value in present_values if (stripped := _normalize_credential_value(value))),
"",
)
return frozenset({authenticated_key}) if authenticated_key else frozenset()
@ -1801,7 +1812,7 @@ def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) -
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`` prefix). Because the value that authenticated is resolved by the
``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
@ -1815,7 +1826,8 @@ def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) -
{
name: value
for name, value in incoming.items()
if name not in _HEADERS_NEVER_FORWARDED_TO_VERTEX and _bearer_stripped(value) not in caller_key_values
if name not in _HEADERS_NEVER_FORWARDED_TO_VERTEX
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

@ -3581,6 +3581,20 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak:
assert "x-litellm-api-key" not in forwarded
assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items())
@pytest.mark.asyncio
@pytest.mark.parametrize("scheme", ["Bearer", "bearer", "Basic"])
async def test_virtual_key_echoed_in_authorization_with_any_scheme_is_stripped(self, monkeypatch, scheme):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-litellm-api-key", self.VKEY.encode()),
(b"authorization", f"{scheme} {self.VKEY}".encode()),
(b"content-type", b"application/json"),
],
)
assert forwarded is None, f"a virtual key echoed as '{scheme} <key>' in Authorization must be stripped, not forwarded"
assert raised is not None and raised.status_code == 401
@pytest.mark.asyncio
async def test_byo_x_goog_api_key_still_forwards_without_virtual_key(self, monkeypatch):
raised, forwarded = await self._run(