Repository navigation
fix(proxy): keep the caller's Google token on credential-less Vertex passthrough under custom auth #38299
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(proxy): keep the caller's Google token on credential-less Vertex passthrough under custom auth #38299
Changes from 2 commits
728b73d
54aebc5
8ec8a2f
7abd82f
be0cac1
a3f6547
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| Use litellm with Anthropic SDK, Vertex AI SDK, Cohere SDK, etc. | ||
| """ | ||
|
|
||
| import hmac | ||
| import json | ||
| import os | ||
| import re | ||
|
|
@@ -48,7 +49,7 @@ | |
| create_websocket_passthrough_route, | ||
| websocket_passthrough_request, | ||
| ) | ||
| from litellm.proxy.utils import is_known_model | ||
| from litellm.proxy.utils import hash_token, is_known_model | ||
| from litellm.proxy.vector_store_endpoints.utils import ( | ||
| assert_proxy_admin_for_vector_store_index_management, | ||
| assert_user_can_access_vector_store, | ||
|
|
@@ -1791,28 +1792,17 @@ def _normalize_credential_value(value: str) -> str: | |
| ) | ||
|
|
||
|
|
||
| _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(), | ||
| ) | ||
|
|
||
| _MAPPED_ROUTE_CALLER_KEY_HEADER: Final = "litellm_user_api_key" | ||
|
|
||
|
|
||
| def _operator_configured_caller_key_header_names() -> tuple[tuple[str, ...], tuple[str, ...]]: | ||
| """Operator-configured caller-key header names, as (override, pass_through). | ||
| def _operator_configured_caller_key_header_names() -> tuple[str, ...]: | ||
| """Operator-configured caller-key header names. | ||
|
|
||
| ``user_api_key_auth`` accepts the caller's key from two runtime-configured | ||
| header sources beyond the built-in ones, at opposite ends of its precedence. | ||
| ``general_settings.litellm_key_header_name`` overrides every built-in source | ||
| (it replaces the resolved key after ``get_api_key`` runs), so it is highest | ||
| precedence. Each ``general_settings.pass_through_endpoints`` entry's | ||
| ``headers.litellm_user_api_key`` is checked last inside ``get_api_key``, so it | ||
| is lowest. Google never consumes either, so both are also dropped by name. | ||
| ``user_api_key_auth`` also reads the caller's key from | ||
| ``general_settings.litellm_key_header_name`` and from each | ||
| ``general_settings.pass_through_endpoints`` entry's | ||
| ``headers.litellm_user_api_key``. Google never consumes either, so both are | ||
| dropped by name. | ||
| """ | ||
| from litellm.proxy.proxy_server import general_settings | ||
|
|
||
|
|
@@ -1829,42 +1819,34 @@ def _operator_configured_caller_key_header_names() -> tuple[tuple[str, ...], tup | |
| if isinstance(headers, dict) and isinstance(headers.get("litellm_user_api_key"), str) | ||
| ) | ||
| ) | ||
| return override, pass_through | ||
| return override + pass_through | ||
|
|
||
|
|
||
| def _authenticated_caller_key_values(request: Request) -> frozenset[str]: | ||
| """The value ``user_api_key_auth`` would accept as this caller's LiteLLM key. | ||
| def _is_authenticated_caller_secret(value: str, user_api_key_dict: UserAPIKeyAuth) -> bool: | ||
| """Whether a header value is a LiteLLM secret that authenticated this caller. | ||
|
|
||
| The Vertex route authenticates through ``Depends(user_api_key_auth)``, which | ||
| resolves the key by precedence, matched here exactly. The ``/vertex_ai`` route | ||
| is a mapped pass-through route, so a header literally named | ||
| ``litellm_user_api_key`` overrides every other source (``user_api_key_auth`` | ||
| applies it last), making it highest precedence. Then an operator | ||
| ``litellm_key_header_name``, then the built-in headers in ``get_api_key`` order, | ||
| then a ``pass_through_endpoints`` ``litellm_user_api_key`` header which | ||
| ``get_api_key`` checks last. 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. | ||
| That is the proxy master key, or a LiteLLM-issued key whose ``hash_token`` is | ||
| the ``api_key`` ``user_api_key_auth`` resolved (a DB virtual key, or a custom | ||
| auth returning the key it validated). A value auth only echoed unhashed (a | ||
| custom auth's own identifier, the no-master-key path returning the raw | ||
| header) or never consumed (JWT auth resolves ``api_key=None``) is not a | ||
| LiteLLM secret, so a bring-your-own Google credential in that position is | ||
| still forwarded. | ||
| """ | ||
| incoming: Final = _safe_get_request_headers(request) | ||
| override_headers, pass_through_headers = _operator_configured_caller_key_header_names() | ||
| ordered_names: Final = ( | ||
| (_MAPPED_ROUTE_CALLER_KEY_HEADER,) | ||
| + override_headers | ||
| + _VERTEX_CALLER_KEY_HEADER_PRECEDENCE | ||
| + pass_through_headers | ||
| ) | ||
| 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 := _normalize_credential_value(value))), | ||
| "", | ||
| ) | ||
| return frozenset({authenticated_key}) if authenticated_key else frozenset() | ||
| from litellm.proxy.proxy_server import master_key | ||
|
|
||
| normalized: Final = _normalize_credential_value(value) | ||
| if master_key is not None and hmac.compare_digest(normalized.encode(), master_key.encode()): | ||
| return True | ||
| authenticated_key: Final = user_api_key_dict.api_key | ||
| if authenticated_key is None: | ||
| return False | ||
| return hmac.compare_digest(hash_token(normalized).encode(), authenticated_key.encode()) | ||
|
greptile-apps[bot] marked this conversation as resolved.
Resolved
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Medium: Custom-auth credentials can be forwarded upstream
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Under custom auth the Authorization value is the caller's own Google token, so marking it consumed strips it and brings back the 401 fixed here
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the detail. I can't automatically confirm this is safe to dismiss, so I'm leaving the thread open for a maintainer to make the call.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The validator applies the same transform on both sides, so opaque and JWT custom-auth credentials strip; tests custom-auth-echoing-opaque-credential and custom-auth-echoing-jwt prove it
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the detail. I can't automatically confirm this is safe to dismiss, so I'm leaving the thread open for a maintainer to make the call. |
||
|
|
||
|
|
||
| def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) -> Mapping[str, str]: | ||
| def _forwarded_headers_for_credentialless_vertex_passthrough( | ||
| request: Request, user_api_key_dict: UserAPIKeyAuth | ||
| ) -> 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. | ||
|
|
@@ -1878,31 +1860,25 @@ def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) - | |
| ``api-key`` / ``x-api-key`` / ``Ocp-Apim-Subscription-Key``, plus the mapped | ||
| pass-through ``litellm_user_api_key`` header and any operator-configured | ||
| ``litellm_key_header_name`` / ``pass_through_endpoints`` key header) 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 authenticated LiteLLM key, which is dropped | ||
| by value (normalizing any ``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 an operator-configured key header) 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. | ||
| 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 | ||
| LiteLLM secret that authenticated this caller (``_is_authenticated_caller_secret``), | ||
| which is dropped by value wherever it appears. Matching on what actually | ||
| authenticated, rather than on header precedence, keeps a caller's own Google | ||
| token in ``Authorization`` flowing when a custom auth or JWT authenticated | ||
| them without consuming it. 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 = _authenticated_caller_key_values(request) | ||
| override_headers, pass_through_headers = _operator_configured_caller_key_header_names() | ||
| never_forwarded: Final = ( | ||
| _HEADERS_NEVER_FORWARDED_TO_VERTEX.union((_MAPPED_ROUTE_CALLER_KEY_HEADER,)) | ||
| .union(override_headers) | ||
| .union(pass_through_headers) | ||
| never_forwarded: Final = _HEADERS_NEVER_FORWARDED_TO_VERTEX.union( | ||
| (_MAPPED_ROUTE_CALLER_KEY_HEADER, *_operator_configured_caller_key_header_names()) | ||
| ) | ||
| forwarded: Final = MappingProxyType( | ||
| { | ||
| name: value | ||
| for name, value in incoming.items() | ||
| if name not in never_forwarded and _normalize_credential_value(value) not in caller_key_values | ||
| if name not in never_forwarded and not _is_authenticated_caller_secret(value, user_api_key_dict) | ||
| } | ||
| ) | ||
| if "authorization" not in forwarded and "x-goog-api-key" not in forwarded: | ||
|
|
@@ -1918,6 +1894,7 @@ async def _prepare_vertex_auth_headers( | |
| vertex_location: str | None, | ||
| base_target_url: str | None, | ||
| get_vertex_pass_through_handler: BaseVertexAIPassThroughHandler, | ||
| user_api_key_dict: UserAPIKeyAuth, | ||
| ) -> tuple[Mapping[str, str], str | None, bool, str | None, str | None]: | ||
| """ | ||
| Prepare authentication headers for Vertex AI pass-through requests. | ||
|
|
@@ -1930,6 +1907,8 @@ async def _prepare_vertex_auth_headers( | |
| vertex_location: Vertex location | ||
| base_target_url: Base URL for the Vertex AI service | ||
| get_vertex_pass_through_handler: Handler for the specific Vertex AI service | ||
| user_api_key_dict: The caller's resolved authentication, so only the secret that | ||
| authenticated them is stripped on the credential-less branch | ||
|
|
||
| Returns: | ||
| Tuple containing: | ||
|
|
@@ -1944,7 +1923,7 @@ 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 = _forwarded_headers_for_credentialless_vertex_passthrough(request) | ||
| headers = _forwarded_headers_for_credentialless_vertex_passthrough(request, user_api_key_dict) | ||
| headers_passed_through = True | ||
| verbose_proxy_logger.debug( | ||
| "default_vertex_config not set, forwarding caller-provided headers %s", tuple(headers.keys()) | ||
|
|
@@ -2104,6 +2083,7 @@ async def _base_vertex_proxy_route( | |
| vertex_location=vertex_location, | ||
| base_target_url=base_target_url, | ||
| get_vertex_pass_through_handler=get_vertex_pass_through_handler, | ||
| user_api_key_dict=user_api_key_dict, | ||
| ) | ||
|
|
||
| if base_target_url is None: | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.