mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
fix(vertex-passthrough): strip virtual key sent via custom key header
user_api_key_auth also authenticates a caller from the operator-configured general_settings.litellm_key_header_name, reading that header straight off the request, so a virtual key sent there survived the credential-less Vertex forwarding filter and reached Google alongside a real bring-your-own credential. Value-strip every header whose value matches the caller's key from any accepted source, including that custom header.
This commit is contained in:
parent
e7c2ede159
commit
ee0363249d
2 changed files with 70 additions and 14 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue