mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
parent
ab93636e2c
commit
f3dc339e07
2 changed files with 62 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue