fix(passthrough): stop leaking the caller's virtual key on credential-less Vertex passthrough

When no Vertex credential is configured (no default_vertex_config, no matching
use_in_pass_through deployment, no vector-store credential), the Vertex passthrough
took the bring-your-own-credentials branch and forwarded the entire incoming header
set upstream to Google. That set included whichever header carried the caller's
LiteLLM virtual key: x-litellm-api-key, or Authorization when get_litellm_virtual_key
read the key from there. The proxy's own secret was sent to a third-party provider.

The credential-less branch now drops x-litellm-api-key and the Authorization value
that equals the virtual key, keeping a genuine bring-your-own Google credential
(an OAuth token in Authorization, or x-goog-api-key) so real BYO passthrough still
works. When neither survives, the request fails with a clean 401 telling the operator
no credential is configured, instead of forwarding the virtual key.

Regression coverage in the mapped test path asserts the 401-and-never-forwarded
behavior for both leak vectors and that a real Google credential still passes through
with the virtual key stripped.
This commit is contained in:
mateo-berri 2026-08-24 11:21:26 -07:00
parent 28b433a007
commit e6eb6a4a4d
2 changed files with 198 additions and 40 deletions

View file

@ -9,7 +9,7 @@ Use litellm with Anthropic SDK, Vertex AI SDK, Cohere SDK, etc.
import json
import os
import re
from collections.abc import Callable
from collections.abc import Callable, Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Annotated, Any, Final, cast
@ -1726,6 +1726,42 @@ def _override_vertex_params_from_router_credentials(
return vertex_project, vertex_location
_CREDENTIALLESS_VERTEX_MISSING_CREDENTIAL_DETAIL: Final = (
"No Vertex AI credential is configured on this proxy and the request carried no upstream "
"Google credential. The LiteLLM virtual key is not forwarded to Google. Configure a Vertex "
"credential (DEFAULT_VERTEXAI_PROJECT / DEFAULT_VERTEXAI_LOCATION / DEFAULT_VERTEXAI_CREDENTIALS, "
"or a model with use_in_pass_through: true), or send your own Google OAuth token in the "
"Authorization header."
)
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.
The LiteLLM virtual key that authenticated the caller is never forwarded to
Google: whichever header carried it (``x-litellm-api-key``, or ``Authorization``
when that is what ``get_litellm_virtual_key`` consumed) is dropped. A caller may
still bring their own Google credential in the ``Authorization`` (OAuth token) or
``x-goog-api-key`` header; when neither is present the request is rejected so the
virtual key cannot leak upstream.
"""
incoming: Final = _safe_get_request_headers(request)
litellm_virtual_key: Final = get_litellm_virtual_key(request)
forwarded: Final = MappingProxyType(
{
name: value
for name, value in incoming.items()
if name not in ("content-length", "host", "x-litellm-api-key")
and not (name == "authorization" and value == litellm_virtual_key)
}
)
if "authorization" not in forwarded and "x-goog-api-key" not in forwarded:
raise HTTPException(status_code=401, detail=_CREDENTIALLESS_VERTEX_MISSING_CREDENTIAL_DETAIL)
return forwarded
async def _prepare_vertex_auth_headers(
request: Request,
vertex_credentials: Any | None,
@ -1734,7 +1770,7 @@ async def _prepare_vertex_auth_headers(
vertex_location: str | None,
base_target_url: str | None,
get_vertex_pass_through_handler: BaseVertexAIPassThroughHandler,
) -> tuple[dict, str | None, bool, str | None, str | None]:
) -> tuple[Mapping[str, str], str | None, bool, str | None, str | None]:
"""
Prepare authentication headers for Vertex AI pass-through requests.
@ -1760,11 +1796,11 @@ async def _prepare_vertex_auth_headers(
# Use headers from the incoming request if no vertex credentials are found
if (vertex_credentials is None or vertex_credentials.vertex_project is None) and router_credentials is None:
headers = _safe_get_request_headers(request).copy()
headers = _forwarded_headers_for_credentialless_vertex_passthrough(request)
headers_passed_through = True
verbose_proxy_logger.debug("default_vertex_config not set, incoming request headers %s", headers)
headers.pop("content-length", None)
headers.pop("host", None)
verbose_proxy_logger.debug(
"default_vertex_config not set, forwarding caller-provided headers %s", tuple(headers.keys())
)
else:
if router_credentials is not None:
vertex_credentials_str = None
@ -1850,7 +1886,7 @@ async def _base_vertex_proxy_route(
encoded_endpoint = httpx.URL(endpoint).path
verbose_proxy_logger.debug("requested endpoint %s", endpoint)
headers: dict = {}
headers: Mapping[str, str] = {}
api_key_to_use = get_litellm_virtual_key(request=request)
user_api_key_dict = await user_api_key_auth(
request=request,

View file

@ -553,10 +553,9 @@ class TestVertexAIPassThroughHandler:
@pytest.mark.asyncio
async def test_vertex_passthrough_with_no_default_credentials(self, monkeypatch):
"""
Test that when no default credentials are set, the request fails
"""
"""
Test that when passthrough credentials are set, they are correctly used in the request
With no Vertex credential matching the request, the only Authorization present
is the caller's own virtual key. It must not be forwarded to Google; the
request fails with a clean 401 instead (LIT-5997).
"""
from litellm.proxy.pass_through_endpoints.passthrough_endpoint_router import (
PassthroughEndpointRouter,
@ -619,31 +618,25 @@ class TestVertexAIPassThroughHandler:
mock_get_token.return_value = (test_token, "")
mock_auth.return_value = MagicMock()
# Call the route
try:
with pytest.raises(HTTPException) as exc_info:
await vertex_proxy_route(
endpoint=endpoint,
request=mock_request,
fastapi_response=mock_response,
)
except Exception as e:
traceback.print_exc()
print(f"Error: {e}")
# Verify create_pass_through_route was called with correct arguments
mock_create_route.assert_called_once_with(
endpoint=endpoint,
target=f"https://{test_location}-aiplatform.googleapis.com/v1/projects/{test_project}/locations/{test_location}/publishers/google/models/gemini-1.5-flash:generateContent",
custom_headers={"authorization": f"Bearer {test_token}"},
is_streaming_request=False,
)
assert exc_info.value.status_code == 401
mock_create_route.assert_not_called()
@pytest.mark.asyncio
async def test_async_vertex_proxy_route_api_key_auth(self):
"""
Critical
This is how Vertex AI JS SDK will Auth to Litellm Proxy
This is how Vertex AI JS SDK will Auth to Litellm Proxy: the virtual key
arrives in x-litellm-api-key and must reach user_api_key_auth. With no Vertex
credential configured, that virtual key must not be forwarded to Google, so
the request fails with a clean 401 (LIT-5997).
"""
# Mock dependencies
mock_request = Mock()
@ -663,14 +656,15 @@ class TestVertexAIPassThroughHandler:
return_value={"status": "success"}
)
# Call the function
result = await vertex_proxy_route(
endpoint="v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-1.5-pro:generateContent",
request=mock_request,
fastapi_response=mock_response,
)
with pytest.raises(HTTPException) as exc_info:
await vertex_proxy_route(
endpoint="v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-1.5-pro:generateContent",
request=mock_request,
fastapi_response=mock_response,
)
# Verify user_api_key_auth was called with the correct Bearer token
assert exc_info.value.status_code == 401
mock_pass_through.assert_not_called()
mock_auth.assert_called_once()
call_args = mock_auth.call_args[1]
assert call_args["api_key"] == "Bearer test-key-123"
@ -1338,7 +1332,9 @@ class TestVertexAIDiscoveryPassThroughHandler:
@pytest.mark.asyncio
async def test_vertex_discovery_proxy_route_api_key_auth(self):
"""
Test that the route correctly handles API key authentication
The virtual key arrives in x-litellm-api-key and must reach user_api_key_auth.
With no Vertex credential configured, that virtual key must not be forwarded to
Google, so the request fails with a clean 401 (LIT-5997).
"""
# Mock dependencies
mock_request = Mock()
@ -1358,14 +1354,15 @@ class TestVertexAIDiscoveryPassThroughHandler:
return_value={"status": "success"}
)
# Call the function
result = await vertex_discovery_proxy_route(
endpoint="v1/projects/test-project/locations/us-central1/dataStores/default/servingConfigs/default:search",
request=mock_request,
fastapi_response=mock_response,
)
with pytest.raises(HTTPException) as exc_info:
await vertex_discovery_proxy_route(
endpoint="v1/projects/test-project/locations/us-central1/dataStores/default/servingConfigs/default:search",
request=mock_request,
fastapi_response=mock_response,
)
# Verify user_api_key_auth was called with the correct Bearer token
assert exc_info.value.status_code == 401
mock_pass_through.assert_not_called()
mock_auth.assert_called_once()
call_args = mock_auth.call_args[1]
assert call_args["api_key"] == "Bearer test-key-123"
@ -3312,7 +3309,10 @@ class TestVertexRawPredictStreamingClassification:
"type": "http",
"method": "POST",
"path": f"/vertex_ai/{endpoint}",
"headers": [(b"content-type", b"application/json")],
"headers": [
(b"content-type", b"application/json"),
(b"authorization", b"Bearer ya29.byo-google-oauth"),
],
"query_string": b"",
},
receive=receive,
@ -3445,6 +3445,128 @@ def test_is_passthrough_request_streaming_tolerates_non_object_bodies(request_bo
assert is_passthrough_request_streaming(request_body) is expected
class TestVertexCredentiallessPassthroughVirtualKeyLeak:
"""Regression coverage for LIT-5997.
With no Vertex credential configured, the passthrough took the
bring-your-own-credentials branch and forwarded the whole incoming header set
to Google, including whichever header carried the caller's LiteLLM virtual key
(``Authorization: Bearer <vkey>`` or ``x-litellm-api-key: <vkey>``). That leaked
the proxy's own secret to an upstream provider.
A credential-less request that carries no upstream Google credential must now
fail with a clean 401 and never reach ``create_pass_through_route``; a genuine
bring-your-own Google credential must still pass through, with the virtual key
stripped from what is forwarded.
"""
VKEY = "sk-litellm-victim-key"
ENDPOINT = (
"v1/projects/my-proj/locations/us-central1/publishers/google/models/"
"gemini-2.5-flash:generateContent"
)
async def _run(
self, monkeypatch, headers: list[tuple[bytes, bytes]]
) -> tuple[HTTPException | None, dict | None]:
from litellm.proxy.pass_through_endpoints.passthrough_endpoint_router import (
PassthroughEndpointRouter,
)
async def receive():
return {"type": "http.request", "body": b"{}", "more_body": False}
request = Request(
{
"type": "http",
"method": "POST",
"path": f"/vertex_ai/{self.ENDPOINT}",
"headers": headers,
"query_string": b"",
},
receive=receive,
)
captured: dict = {}
def fake_create_pass_through_route(**kwargs):
captured.update(kwargs)
return AsyncMock(return_value={"status": "success"})
mock_handler = Mock()
mock_handler.get_default_base_target_url.return_value = "https://us-central1-aiplatform.googleapis.com/"
module = "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints"
monkeypatch.setattr(f"{module}.passthrough_endpoint_router", PassthroughEndpointRouter())
raised: HTTPException | None = None
with (
mock.patch(f"{module}.create_pass_through_route", side_effect=fake_create_pass_through_route),
mock.patch(f"{module}.user_api_key_auth", new=AsyncMock(return_value=UserAPIKeyAuth(token="hashed"))),
mock.patch(f"{module}.get_vertex_pass_through_handler", return_value=mock_handler),
):
try:
await vertex_proxy_route(
endpoint=self.ENDPOINT,
request=request,
fastapi_response=Response(),
user_api_key_dict=UserAPIKeyAuth(token="hashed"),
)
except HTTPException as exc:
raised = exc
return raised, (captured.get("custom_headers") if captured else None)
@pytest.mark.asyncio
async def test_authorization_bearer_virtual_key_is_rejected_not_forwarded(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[(b"authorization", f"Bearer {self.VKEY}".encode()), (b"content-type", b"application/json")],
)
assert forwarded is None, "credential-less request must never reach the upstream forwarder"
assert raised is not None and raised.status_code == 401
@pytest.mark.asyncio
async def test_x_litellm_api_key_virtual_key_is_rejected_not_forwarded(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[(b"x-litellm-api-key", self.VKEY.encode()), (b"content-type", b"application/json")],
)
assert forwarded is None, "credential-less request must never reach the upstream forwarder"
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(
monkeypatch,
[
(b"x-litellm-api-key", self.VKEY.encode()),
(b"authorization", b"Bearer ya29.google-oauth-token"),
(b"content-type", b"application/json"),
],
)
assert raised is None
assert forwarded is not None
assert forwarded.get("authorization") == "Bearer ya29.google-oauth-token"
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
async def test_byo_x_goog_api_key_still_forwards_without_virtual_key(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-litellm-api-key", self.VKEY.encode()),
(b"x-goog-api-key", b"AIza-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-google-api-key"
assert "x-litellm-api-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``.