security: move Gemini API keys from URL query params to x-goog-api-key header

API keys passed as ?key=<KEY> in URLs leak into httpx error tracebacks
when requests fail. Move all Google AI Studio / Gemini API key usage
from URL query parameters to the x-goog-api-key HTTP header, which
is not included in httpx.HTTPStatusError messages.

WebSocket URLs (realtime) are excluded since WS clients do not support
custom HTTP headers universally.

Backport of BerriAI/litellm#25117 to v1.81.3-stable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
user 2026-04-04 01:24:40 +00:00
parent 61ed8f9e03
commit f9ac3e43bc
No known key found for this signature in database
9 changed files with 66 additions and 51 deletions

View file

@ -28,7 +28,9 @@ class GeminiModelInfo(BaseLLMModelInfo):
api_key: Optional[str] = None,
api_base: Optional[str] = None,
) -> dict:
"""Google AI Studio sends api key in query params"""
"""Google AI Studio uses x-goog-api-key header for authentication."""
if api_key:
headers["x-goog-api-key"] = api_key
return headers
@property
@ -71,7 +73,8 @@ class GeminiModelInfo(BaseLLMModelInfo):
)
response = litellm.module_level_client.get(
url=f"{api_base}{endpoint}?key={api_key}",
url=f"{api_base}{endpoint}",
headers={"x-goog-api-key": api_key},
)
if response.status_code != 200:

View file

@ -59,7 +59,7 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig):
if not api_key:
raise ValueError("api_key is required")
url = "{}/{}?key={}".format(api_base, endpoint, api_key)
url = "{}/{}".format(api_base, endpoint)
return url
def get_supported_openai_params(

View file

@ -66,9 +66,13 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
model: str,
litellm_params: Optional[GenericLiteLLMParams],
) -> dict:
"""Google AI Studio uses API key in query params, not headers."""
"""Google AI Studio uses x-goog-api-key header for authentication."""
headers = headers or {}
headers["Content-Type"] = "application/json"
if litellm_params:
api_key = GeminiModelInfo.get_api_key(litellm_params.get("api_key"))
if api_key:
headers["x-goog-api-key"] = api_key
return headers
def get_complete_url(
@ -89,11 +93,10 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
"Google API key is required. Set GOOGLE_API_KEY or GEMINI_API_KEY environment variable."
)
query_params = f"key={api_key}"
if stream:
query_params += "&alt=sse"
return f"{api_base}/{self.api_version}/interactions?{query_params}"
return f"{api_base}/{self.api_version}/interactions?alt=sse"
return f"{api_base}/{self.api_version}/interactions"
def transform_request(
self,
@ -182,10 +185,9 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
) -> Tuple[str, Dict]:
"""GET /{api_version}/interactions/{interaction_id}"""
resolved_api_base = GeminiModelInfo.get_api_base(api_base)
api_key = GeminiModelInfo.get_api_key(litellm_params.api_key)
if not api_key:
if not GeminiModelInfo.get_api_key(litellm_params.api_key):
raise ValueError("Google API key is required")
return f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}?key={api_key}", {}
return f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}", {}
def transform_get_interaction_response(
self,
@ -213,10 +215,9 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
) -> Tuple[str, Dict]:
"""DELETE /{api_version}/interactions/{interaction_id}"""
resolved_api_base = GeminiModelInfo.get_api_base(api_base)
api_key = GeminiModelInfo.get_api_key(litellm_params.api_key)
if not api_key:
if not GeminiModelInfo.get_api_key(litellm_params.api_key):
raise ValueError("Google API key is required")
return f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}?key={api_key}", {}
return f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}", {}
def transform_delete_interaction_response(
self,
@ -241,10 +242,9 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
) -> Tuple[str, Dict]:
"""POST /{api_version}/interactions/{interaction_id}:cancel (if supported)"""
resolved_api_base = GeminiModelInfo.get_api_base(api_base)
api_key = GeminiModelInfo.get_api_key(litellm_params.api_key)
if not api_key:
if not GeminiModelInfo.get_api_key(litellm_params.api_key):
raise ValueError("Google API key is required")
return f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}:cancel?key={api_key}", {}
return f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}:cancel", {}
def transform_cancel_interaction_response(
self,

View file

@ -85,6 +85,10 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
raise ValueError("api_key is required for Gemini API calls")
api_base = api_base.replace("https://", "wss://")
api_base = api_base.replace("http://", "ws://")
# WebSocket connections do not support custom HTTP headers in all clients,
# so the API key must remain as a query parameter here. This is an accepted
# limitation; httpx is not used for WebSocket so MaskedHTTPStatusError
# already covers the main leak vector.
return f"{api_base}/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key={api_key}"
def map_model_turn_event(

View file

@ -48,7 +48,7 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig):
def get_auth_credentials(
self, litellm_params: dict
) -> BaseVectorStoreAuthCredentials:
"""Gemini uses API key in query params, not headers."""
"""Gemini uses x-goog-api-key header for authentication."""
return {}
def get_vector_store_endpoints_by_type(self) -> VectorStoreIndexEndpoints:
@ -79,7 +79,8 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig):
api_key = litellm_params.get("api_key") or get_api_key_from_env()
if api_key:
self._cached_api_key = api_key
headers["x-goog-api-key"] = api_key
return headers
def get_complete_url(self, api_base: Optional[str], litellm_params: dict) -> str:
@ -133,13 +134,10 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig):
if model and model.startswith("gemini/"):
model = model.replace("gemini/", "")
# Get API key - Gemini requires it as a query parameter
api_key = litellm_params.get("api_key") or GeminiModelInfo.get_api_key()
if not api_key:
raise ValueError("GEMINI_API_KEY or GOOGLE_API_KEY is required")
# Build the URL for generateContent with API key
url = f"{api_base}/models/{model}:generateContent?key={api_key}"
url = f"{api_base}/models/{model}:generateContent"
# Build file_search tool configuration (using snake_case as per Gemini docs)
file_search_config: Dict[str, Any] = {
@ -289,11 +287,8 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig):
Transform create request to Gemini's fileSearchStores format.
"""
url = f"{api_base}/fileSearchStores"
# Append API key as query parameter (required by Gemini)
api_key = self._cached_api_key or get_api_key_from_env()
if api_key:
url = f"{url}?key={api_key}"
# API key is passed via x-goog-api-key header (set in validate_environment)
request_body: Dict[str, Any] = {}

View file

@ -330,8 +330,14 @@ def _get_gemini_url(
mode: all_gemini_url_modes,
model: str,
stream: Optional[bool],
gemini_api_key: Optional[str],
gemini_api_key: Optional[str] = None,
) -> Tuple[str, str]:
"""Build the Gemini API URL for the given mode.
The API key is NOT included in the URL. Callers must pass it via the
``x-goog-api-key`` header instead to avoid leaking credentials in
error tracebacks.
"""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
@ -343,29 +349,29 @@ def _get_gemini_url(
endpoint = "generateContent"
if stream is True:
endpoint = "streamGenerateContent"
url = "https://generativelanguage.googleapis.com/{}/{}:{}?key={}&alt=sse".format(
api_version, _gemini_model_name, endpoint, gemini_api_key
url = "https://generativelanguage.googleapis.com/{}/{}:{}?alt=sse".format(
api_version, _gemini_model_name, endpoint
)
else:
url = (
"https://generativelanguage.googleapis.com/{}/{}:{}?key={}".format(
api_version, _gemini_model_name, endpoint, gemini_api_key
"https://generativelanguage.googleapis.com/{}/{}:{}".format(
api_version, _gemini_model_name, endpoint
)
)
elif mode == "embedding":
endpoint = "embedContent"
url = "https://generativelanguage.googleapis.com/v1beta/{}:{}?key={}".format(
_gemini_model_name, endpoint, gemini_api_key
url = "https://generativelanguage.googleapis.com/v1beta/{}:{}".format(
_gemini_model_name, endpoint
)
elif mode == "batch_embedding":
endpoint = "batchEmbedContents"
url = "https://generativelanguage.googleapis.com/v1beta/{}:{}?key={}".format(
_gemini_model_name, endpoint, gemini_api_key
url = "https://generativelanguage.googleapis.com/v1beta/{}:{}".format(
_gemini_model_name, endpoint
)
elif mode == "count_tokens":
endpoint = "countTokens"
url = "https://generativelanguage.googleapis.com/v1beta/{}:{}?key={}".format(
_gemini_model_name, endpoint, gemini_api_key
url = "https://generativelanguage.googleapis.com/v1beta/{}:{}".format(
_gemini_model_name, endpoint
)
elif mode == "image_generation":
raise ValueError(

View file

@ -56,10 +56,10 @@ class ContextCachingEndpoints(VertexBase):
token, url
"""
if custom_llm_provider == "gemini":
auth_header = None
auth_header = {"x-goog-api-key": gemini_api_key} # type: ignore[assignment]
endpoint = "cachedContents"
url = "https://generativelanguage.googleapis.com/v1beta/{}?key={}".format(
endpoint, gemini_api_key
url = "https://generativelanguage.googleapis.com/v1beta/{}".format(
endpoint
)
elif custom_llm_provider == "vertex_ai":
auth_header = vertex_auth_header
@ -287,7 +287,9 @@ class ContextCachingEndpoints(VertexBase):
headers = {
"Content-Type": "application/json",
}
if token is not None:
if isinstance(token, dict):
headers.update(token)
elif token is not None:
headers["Authorization"] = f"Bearer {token}"
if extra_headers is not None:
headers.update(extra_headers)
@ -419,7 +421,9 @@ class ContextCachingEndpoints(VertexBase):
headers = {
"Content-Type": "application/json",
}
if token is not None:
if isinstance(token, dict):
headers.update(token)
elif token is not None:
headers["Authorization"] = f"Bearer {token}"
if extra_headers is not None:
headers.update(extra_headers)

View file

@ -420,9 +420,8 @@ class VertexBase:
mode=mode,
model=model,
stream=stream,
gemini_api_key=gemini_api_key,
)
auth_header = None # this field is not used for gemin
auth_header = {"x-goog-api-key": gemini_api_key} # type: ignore[assignment]
else:
vertex_location = self.get_vertex_region(
vertex_region=vertex_location,

View file

@ -135,12 +135,12 @@ class GeminiRAGIngestion(BaseRAGIngestion):
Returns:
Store name (format: fileSearchStores/xxxxxxx)
"""
url = f"{base_url}/fileSearchStores?key={api_key}"
url = f"{base_url}/fileSearchStores"
request_body = {
"displayName": display_name
}
client = get_async_httpx_client(
llm_provider=httpxSpecialProvider.RAG,
params={"timeout": 60.0},
@ -148,7 +148,10 @@ class GeminiRAGIngestion(BaseRAGIngestion):
response = await client.post(
url,
json=request_body,
headers={"Content-Type": "application/json"},
headers={
"Content-Type": "application/json",
"x-goog-api-key": api_key,
},
)
if response.status_code != 200:
@ -222,7 +225,7 @@ class GeminiRAGIngestion(BaseRAGIngestion):
# base_url is like: https://generativelanguage.googleapis.com/v1beta
# We need: https://generativelanguage.googleapis.com/upload/v1beta/{store_id}:uploadToFileSearchStore
api_base = base_url.replace("/v1beta", "") # Get base without version
url = f"{api_base}/upload/v1beta/{vector_store_id}:uploadToFileSearchStore?key={api_key}"
url = f"{api_base}/upload/v1beta/{vector_store_id}:uploadToFileSearchStore"
# Build request body with chunking config and metadata if provided
request_body: Dict[str, Any] = {
@ -252,6 +255,7 @@ class GeminiRAGIngestion(BaseRAGIngestion):
"X-Goog-Upload-Header-Content-Length": str(file_size),
"X-Goog-Upload-Header-Content-Type": content_type,
"Content-Type": "application/json",
"x-goog-api-key": api_key,
}
verbose_logger.debug(f"Initiating resumable upload: {url}")