mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
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:
parent
28b433a007
commit
e6eb6a4a4d
2 changed files with 198 additions and 40 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue