mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(search): block server credential leak to caller-supplied api_base (#30682)
Search providers resolved the server-configured API key (e.g.
get_secret_str("SERPER_API_KEY")) in validate_environment whenever the
caller omitted api_key, while get_complete_url independently honored a
caller-supplied api_base. A caller who passes their own api_base and no
api_key therefore made the proxy send the operator's provider key to a
host they control; POST /search_tools/test_connection forwards
request-body api_base/api_key straight into asearch, so any authenticated
user could exfiltrate the server's search credentials.
Add a shared host-aware fallback in BaseSearchConfig.resolve_server_api_key
that only applies a server-managed secret when the caller-supplied
api_base is absent or resolves to a trusted host (the provider default or
the operator's own *_API_BASE env override); otherwise it refuses and asks
for an explicit api_key. The guard only triggers when a server secret
actually exists, so keyless and self-hosted providers (searxng, you.com
free tier) keep working. Every provider that carries a server secret is
migrated to the helper; dataforseo reuses the same guard for its
login:password basic-auth credentials.
This changes behavior for callers that previously passed a per-request
api_base while relying on a server-configured key: they must now pass an
explicit api_key, or the operator must configure the base via the
provider's *_API_BASE env var (which stays trusted).
This commit is contained in:
parent
c546b58c09
commit
9c014716ec
24 changed files with 582 additions and 49 deletions
|
|
@ -53,7 +53,13 @@ class APISerpentSearchConfig(BaseSearchConfig):
|
|||
api_base: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Dict:
|
||||
api_key = api_key or get_secret_str("APISERPENT_API_KEY")
|
||||
api_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("APISERPENT_API_KEY",),
|
||||
base_env_var="APISERPENT_API_BASE",
|
||||
default_api_base=APISERPENT_BASE,
|
||||
)
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"APISERPENT_API_KEY is not set. Set `APISERPENT_API_KEY` environment variable."
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ Base Search transformation configuration.
|
|||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.base import LiteLLMPydanticObjectBase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -16,6 +18,29 @@ else:
|
|||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
||||
def _search_host(url: str) -> str:
|
||||
return urlsplit(url).netloc.lower()
|
||||
|
||||
|
||||
def _is_trusted_search_api_base(
|
||||
caller_api_base: str,
|
||||
default_api_base: str | None,
|
||||
base_env_var: str | None,
|
||||
) -> bool:
|
||||
candidate = _search_host(caller_api_base)
|
||||
if not candidate:
|
||||
return False
|
||||
trusted = {
|
||||
_search_host(base)
|
||||
for base in (
|
||||
default_api_base,
|
||||
get_secret_str(base_env_var) if base_env_var else None,
|
||||
)
|
||||
if base
|
||||
}
|
||||
return candidate in trusted
|
||||
|
||||
|
||||
class SearchResult(LiteLLMPydanticObjectBase):
|
||||
"""Single search result."""
|
||||
|
||||
|
|
@ -86,6 +111,60 @@ class BaseSearchConfig:
|
|||
"max_tokens_per_page",
|
||||
}
|
||||
|
||||
def _assert_trusted_api_base_for_server_credential(
|
||||
self,
|
||||
caller_api_base: str | None,
|
||||
default_api_base: str | None,
|
||||
base_env_var: str | None,
|
||||
credential_name: str,
|
||||
) -> None:
|
||||
"""
|
||||
Block sending a server-managed credential to a caller-chosen host.
|
||||
|
||||
A caller-supplied api_base is honored when constructing the request URL, so
|
||||
falling back to a server-configured secret while the caller controls the host
|
||||
leaks that secret. The provider default and the operator's own api_base
|
||||
override are the only trusted destinations for a server-managed credential.
|
||||
"""
|
||||
if not caller_api_base:
|
||||
return
|
||||
if _is_trusted_search_api_base(caller_api_base, default_api_base, base_env_var):
|
||||
return
|
||||
raise ValueError(
|
||||
f"Refusing to send the server-configured {credential_name} to the "
|
||||
f"caller-supplied api_base '{caller_api_base}'. Pass an explicit api_key "
|
||||
f"when overriding api_base for this search provider."
|
||||
)
|
||||
|
||||
def resolve_server_api_key(
|
||||
self,
|
||||
*,
|
||||
caller_api_key: str | None,
|
||||
caller_api_base: str | None,
|
||||
key_env_vars: tuple[str, ...],
|
||||
base_env_var: str | None,
|
||||
default_api_base: str | None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Resolve a single-secret search API key, falling back to a server-managed
|
||||
secret only when the request targets a trusted host.
|
||||
|
||||
Returns the caller's key when provided, otherwise the first set
|
||||
server-managed secret (or None when none is set, for keyless providers).
|
||||
"""
|
||||
if caller_api_key:
|
||||
return caller_api_key
|
||||
server_key = next(
|
||||
(key for key in (get_secret_str(var) for var in key_env_vars) if key),
|
||||
None,
|
||||
)
|
||||
if server_key is None:
|
||||
return None
|
||||
self._assert_trusted_api_base_for_server_credential(
|
||||
caller_api_base, default_api_base, base_env_var, key_env_vars[0]
|
||||
)
|
||||
return server_key
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: Dict,
|
||||
|
|
|
|||
|
|
@ -115,7 +115,13 @@ class BraveSearchConfig(BaseSearchConfig):
|
|||
"""
|
||||
Validate environment and return headers.
|
||||
"""
|
||||
api_key = api_key or get_secret_str("BRAVE_API_KEY")
|
||||
api_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("BRAVE_API_KEY",),
|
||||
base_env_var="BRAVE_API_BASE",
|
||||
default_api_base=self.BRAVE_API_BASE,
|
||||
)
|
||||
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
|
|
|
|||
|
|
@ -1879,6 +1879,9 @@ class BaseLLMHTTPHandler:
|
|||
data = provider_config.transform_search_request(
|
||||
query=query,
|
||||
optional_params=optional_params,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
headers=headers or {},
|
||||
)
|
||||
|
||||
# Get complete URL (pass data for providers that need request body for URL construction)
|
||||
|
|
|
|||
|
|
@ -61,9 +61,18 @@ class DataForSEOSearchConfig(BaseSearchConfig):
|
|||
password = get_secret_str("DATAFORSEO_PASSWORD")
|
||||
|
||||
# If api_key is provided in "login:password" format, use it
|
||||
caller_supplied_credentials = bool(api_key and ":" in api_key)
|
||||
if api_key and ":" in api_key:
|
||||
login, password = api_key.split(":", 1)
|
||||
|
||||
if not caller_supplied_credentials and login and password:
|
||||
self._assert_trusted_api_base_for_server_credential(
|
||||
api_base,
|
||||
self.DATAFORSEO_API_BASE,
|
||||
"DATAFORSEO_API_BASE",
|
||||
"DATAFORSEO_LOGIN",
|
||||
)
|
||||
|
||||
if not login:
|
||||
raise ValueError(
|
||||
"DATAFORSEO_LOGIN is not set. Set `DATAFORSEO_LOGIN` environment variable or pass credentials in api_key parameter."
|
||||
|
|
|
|||
|
|
@ -65,7 +65,13 @@ class ExaAISearchConfig(BaseSearchConfig):
|
|||
"""
|
||||
Validate environment and return headers.
|
||||
"""
|
||||
api_key = api_key or get_secret_str("EXA_API_KEY")
|
||||
api_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("EXA_API_KEY",),
|
||||
base_env_var="EXA_API_BASE",
|
||||
default_api_base=self.EXA_AI_API_BASE,
|
||||
)
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"EXA_API_KEY is not set. Set `EXA_API_KEY` environment variable."
|
||||
|
|
|
|||
|
|
@ -57,7 +57,13 @@ class FastCRWSearchConfig(BaseSearchConfig):
|
|||
"""
|
||||
Validate environment and return headers.
|
||||
"""
|
||||
api_key = api_key or get_secret_str("CRW_API_KEY")
|
||||
api_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("CRW_API_KEY",),
|
||||
base_env_var="CRW_API_BASE",
|
||||
default_api_base=self.FASTCRW_API_BASE,
|
||||
)
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"CRW_API_KEY is not set. Set `CRW_API_KEY` environment variable."
|
||||
|
|
|
|||
|
|
@ -61,7 +61,13 @@ class FirecrawlSearchConfig(BaseSearchConfig):
|
|||
"""
|
||||
Validate environment and return headers.
|
||||
"""
|
||||
api_key = api_key or get_secret_str("FIRECRAWL_API_KEY")
|
||||
api_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("FIRECRAWL_API_KEY",),
|
||||
base_env_var="FIRECRAWL_API_BASE",
|
||||
default_api_base=self.FIRECRAWL_API_BASE,
|
||||
)
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"FIRECRAWL_API_KEY is not set. Set `FIRECRAWL_API_KEY` environment variable."
|
||||
|
|
|
|||
|
|
@ -85,7 +85,13 @@ class GooglePSESearchConfig(BaseSearchConfig):
|
|||
Google PSE uses API key as a query parameter, not in headers.
|
||||
This method is called but headers are not used for authentication.
|
||||
"""
|
||||
api_key = api_key or get_secret_str("GOOGLE_PSE_API_KEY")
|
||||
api_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("GOOGLE_PSE_API_KEY",),
|
||||
base_env_var="GOOGLE_PSE_API_BASE",
|
||||
default_api_base=self.GOOGLE_PSE_API_BASE,
|
||||
)
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"GOOGLE_PSE_API_KEY is not set. Set `GOOGLE_PSE_API_KEY` environment variable."
|
||||
|
|
@ -137,6 +143,7 @@ class GooglePSESearchConfig(BaseSearchConfig):
|
|||
query: Union[str, List[str]],
|
||||
optional_params: dict,
|
||||
api_key: Optional[str] = None,
|
||||
api_base: str | None = None,
|
||||
search_engine_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Dict:
|
||||
|
|
@ -165,8 +172,16 @@ class GooglePSESearchConfig(BaseSearchConfig):
|
|||
# Google PSE only supports single string queries
|
||||
query = " ".join(query)
|
||||
|
||||
# Get API credentials
|
||||
api_key = api_key or get_secret_str("GOOGLE_PSE_API_KEY")
|
||||
# Get API credentials. The key is sent as a query param to api_base, so
|
||||
# resolve it host-aware to avoid leaking a server-managed key to a
|
||||
# caller-supplied host.
|
||||
api_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("GOOGLE_PSE_API_KEY",),
|
||||
base_env_var="GOOGLE_PSE_API_BASE",
|
||||
default_api_base=self.GOOGLE_PSE_API_BASE,
|
||||
)
|
||||
search_engine_id = search_engine_id or get_secret_str("GOOGLE_PSE_ENGINE_ID")
|
||||
|
||||
if not api_key:
|
||||
|
|
|
|||
|
|
@ -61,7 +61,13 @@ class LinkupSearchConfig(BaseSearchConfig):
|
|||
"""
|
||||
Validate environment and return headers.
|
||||
"""
|
||||
api_key = api_key or get_secret_str("LINKUP_API_KEY")
|
||||
api_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("LINKUP_API_KEY",),
|
||||
base_env_var="LINKUP_API_BASE",
|
||||
default_api_base=self.LINKUP_API_BASE,
|
||||
)
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"LINKUP_API_KEY is not set. Set `LINKUP_API_KEY` environment variable."
|
||||
|
|
|
|||
|
|
@ -67,10 +67,12 @@ class ParallelAISearchConfig(BaseSearchConfig):
|
|||
api_base: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Dict:
|
||||
api_key = (
|
||||
api_key
|
||||
or get_secret_str("PARALLEL_AI_API_KEY")
|
||||
or get_secret_str("PARALLEL_API_KEY")
|
||||
api_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("PARALLEL_AI_API_KEY", "PARALLEL_API_KEY"),
|
||||
base_env_var="PARALLEL_AI_API_BASE",
|
||||
default_api_base=self.PARALLEL_AI_API_BASE,
|
||||
)
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
|
|
|
|||
|
|
@ -50,7 +50,13 @@ class PerplexitySearchConfig(BaseSearchConfig):
|
|||
"""
|
||||
Validate environment and return headers.
|
||||
"""
|
||||
api_key = api_key or get_secret_str("PERPLEXITYAI_API_KEY")
|
||||
api_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("PERPLEXITYAI_API_KEY",),
|
||||
base_env_var="PERPLEXITY_API_BASE",
|
||||
default_api_base=self.PERPLEXITY_API_BASE,
|
||||
)
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"PERPLEXITYAI_API_KEY is not set. Set `PERPLEXITYAI_API_KEY` environment variable."
|
||||
|
|
|
|||
|
|
@ -74,7 +74,13 @@ class SearchAPIConfig(BaseSearchConfig):
|
|||
"""
|
||||
Validate environment and return headers.
|
||||
"""
|
||||
api_key = api_key or get_secret_str("SEARCHAPI_API_KEY")
|
||||
api_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("SEARCHAPI_API_KEY",),
|
||||
base_env_var="SEARCHAPI_API_BASE",
|
||||
default_api_base=self.SEARCHAPI_API_BASE,
|
||||
)
|
||||
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
|
|
@ -114,6 +120,7 @@ class SearchAPIConfig(BaseSearchConfig):
|
|||
query: Union[str, List[str]],
|
||||
optional_params: dict,
|
||||
api_key: Optional[str] = None,
|
||||
api_base: str | None = None,
|
||||
search_engine_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Dict:
|
||||
|
|
@ -137,8 +144,16 @@ class SearchAPIConfig(BaseSearchConfig):
|
|||
if isinstance(query, list):
|
||||
query = " ".join(query)
|
||||
|
||||
# Get API key from parameter or environment
|
||||
api_key = api_key or get_secret_str("SEARCHAPI_API_KEY")
|
||||
# Get API key from parameter or environment. The key is sent as a query
|
||||
# param to api_base, so resolve it host-aware to avoid leaking a
|
||||
# server-managed key to a caller-supplied host.
|
||||
api_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("SEARCHAPI_API_KEY",),
|
||||
base_env_var="SEARCHAPI_API_BASE",
|
||||
default_api_base=self.SEARCHAPI_API_BASE,
|
||||
)
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"SEARCHAPI_API_KEY is not set. Set `SEARCHAPI_API_KEY` environment variable."
|
||||
|
|
|
|||
|
|
@ -61,7 +61,13 @@ class SearXNGSearchConfig(BaseSearchConfig):
|
|||
Some instances may require authentication via headers.
|
||||
"""
|
||||
# SearXNG typically doesn't require API keys, but support optional auth
|
||||
api_key = api_key or get_secret_str("SEARXNG_API_KEY")
|
||||
api_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("SEARXNG_API_KEY",),
|
||||
base_env_var="SEARXNG_API_BASE",
|
||||
default_api_base=None,
|
||||
)
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
|
|
|||
|
|
@ -55,7 +55,13 @@ class SerperSearchConfig(BaseSearchConfig):
|
|||
"""
|
||||
Validate environment and return headers.
|
||||
"""
|
||||
api_key = api_key or get_secret_str("SERPER_API_KEY")
|
||||
api_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("SERPER_API_KEY",),
|
||||
base_env_var="SERPER_API_BASE",
|
||||
default_api_base=self.SERPER_API_BASE,
|
||||
)
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"SERPER_API_KEY is not set. Set `SERPER_API_KEY` environment variable."
|
||||
|
|
|
|||
|
|
@ -64,7 +64,13 @@ class TavilySearchConfig(BaseSearchConfig):
|
|||
"""
|
||||
Validate environment and return headers.
|
||||
"""
|
||||
api_key = api_key or get_secret_str("TAVILY_API_KEY")
|
||||
api_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("TAVILY_API_KEY",),
|
||||
base_env_var="TAVILY_API_BASE",
|
||||
default_api_base=self.TAVILY_API_BASE,
|
||||
)
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"TAVILY_API_KEY is not set. Set `TAVILY_API_KEY` environment variable."
|
||||
|
|
|
|||
|
|
@ -67,7 +67,13 @@ class TinyfishSearchConfig(BaseSearchConfig):
|
|||
api_base: str | None = None,
|
||||
**kwargs: object,
|
||||
) -> dict[str, str]:
|
||||
resolved_key = api_key or get_secret_str("TINYFISH_API_KEY")
|
||||
resolved_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("TINYFISH_API_KEY",),
|
||||
base_env_var="TINYFISH_API_BASE",
|
||||
default_api_base=self.TINYFISH_API_BASE,
|
||||
)
|
||||
if not resolved_key:
|
||||
raise ValueError(
|
||||
"TINYFISH_API_KEY is not set. Set `TINYFISH_API_KEY` environment variable."
|
||||
|
|
|
|||
|
|
@ -64,7 +64,13 @@ class YouComSearchConfig(BaseSearchConfig):
|
|||
endpoint with the `X-API-Key` header. Otherwise fall through to the
|
||||
keyless free tier; no auth header is required.
|
||||
"""
|
||||
api_key = api_key or get_secret_str("YOUCOM_API_KEY")
|
||||
api_key = self.resolve_server_api_key(
|
||||
caller_api_key=api_key,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=("YOUCOM_API_KEY",),
|
||||
base_env_var="YOUCOM_API_BASE",
|
||||
default_api_base=self.YOU_COM_API_BASE,
|
||||
)
|
||||
headers["Content-Type"] = "application/json"
|
||||
# Pin Accept-Encoding to identity: the keyless `api.you.com/v1/agents/search`
|
||||
# endpoint advertises gzip content-encoding but returns body bytes the
|
||||
|
|
|
|||
|
|
@ -46,10 +46,9 @@ class TestSearchAPIConfig:
|
|||
|
||||
assert result["Content-Type"] == "application/json"
|
||||
|
||||
@patch("litellm.llms.searchapi.search.transformation.get_secret_str")
|
||||
def test_validate_environment_without_api_key(self, mock_get_secret):
|
||||
def test_validate_environment_without_api_key(self, monkeypatch):
|
||||
"""Test environment validation without API key raises error."""
|
||||
mock_get_secret.return_value = None
|
||||
monkeypatch.delenv("SEARCHAPI_API_KEY", raising=False)
|
||||
config = SearchAPIConfig()
|
||||
headers = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -318,13 +318,11 @@ class TestSearXNGSearchHeaders:
|
|||
assert headers["Content-Type"] == "application/json"
|
||||
assert headers["Authorization"] == "Bearer test-key-123"
|
||||
|
||||
def test_headers_with_env_api_key(self):
|
||||
def test_headers_with_env_api_key(self, monkeypatch):
|
||||
"""Test that headers use SEARXNG_API_KEY from env."""
|
||||
with patch(
|
||||
"litellm.llms.searxng.search.transformation.get_secret_str",
|
||||
return_value="env-key-456",
|
||||
):
|
||||
headers = self.config.validate_environment(headers={})
|
||||
monkeypatch.setenv("SEARXNG_API_KEY", "env-key-456")
|
||||
|
||||
headers = self.config.validate_environment(headers={})
|
||||
|
||||
assert headers["Authorization"] == "Bearer env-key-456"
|
||||
|
||||
|
|
|
|||
|
|
@ -66,9 +66,8 @@ class TestAPISerpentConfig:
|
|||
assert headers["X-API-Key"] == "test-api-key"
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
|
||||
@patch("litellm.llms.apiserpent.search.transformation.get_secret_str")
|
||||
def test_validate_environment_without_api_key(self, mock_get_secret):
|
||||
mock_get_secret.return_value = None
|
||||
def test_validate_environment_without_api_key(self, monkeypatch):
|
||||
monkeypatch.delenv("APISERPENT_API_KEY", raising=False)
|
||||
with pytest.raises(ValueError, match="APISERPENT_API_KEY is not set"):
|
||||
APISerpentSearchConfig().validate_environment({})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,329 @@
|
|||
"""
|
||||
Regression tests for the host-aware server-credential fallback guard in
|
||||
``BaseSearchConfig``.
|
||||
|
||||
A caller-supplied ``api_base`` is honored when building the request URL, so
|
||||
falling back to a server-configured secret while the caller controls the host
|
||||
would send the operator's credential to an attacker. The guard must refuse that
|
||||
combination for every provider that carries a server-managed secret, while
|
||||
leaving keyless providers and legitimate operator overrides untouched.
|
||||
"""
|
||||
|
||||
from typing import Dict, Tuple, Type
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.llms.apiserpent.search.transformation import APISerpentSearchConfig
|
||||
from litellm.llms.base_llm.search.transformation import (
|
||||
BaseSearchConfig,
|
||||
_is_trusted_search_api_base,
|
||||
)
|
||||
from litellm.llms.brave.search.transformation import BraveSearchConfig
|
||||
from litellm.llms.dataforseo.search.transformation import DataForSEOSearchConfig
|
||||
from litellm.llms.exa_ai.search.transformation import ExaAISearchConfig
|
||||
from litellm.llms.fastcrw.search.transformation import FastCRWSearchConfig
|
||||
from litellm.llms.firecrawl.search.transformation import FirecrawlSearchConfig
|
||||
from litellm.llms.google_pse.search.transformation import GooglePSESearchConfig
|
||||
from litellm.llms.linkup.search.transformation import LinkupSearchConfig
|
||||
from litellm.llms.parallel_ai.search.transformation import ParallelAISearchConfig
|
||||
from litellm.llms.perplexity.search.transformation import PerplexitySearchConfig
|
||||
from litellm.llms.searchapi.search.transformation import SearchAPIConfig
|
||||
from litellm.llms.searxng.search.transformation import SearXNGSearchConfig
|
||||
from litellm.llms.serper.search.transformation import SerperSearchConfig
|
||||
from litellm.llms.tavily.search.transformation import TavilySearchConfig
|
||||
from litellm.llms.tinyfish.search.transformation import TinyfishSearchConfig
|
||||
from litellm.llms.you_com.search.transformation import YouComSearchConfig
|
||||
|
||||
ATTACKER_BASE = "https://attacker.example.com"
|
||||
|
||||
# Every *_API_BASE override env var that could otherwise mark the attacker host
|
||||
# as trusted; cleared before each test so the suite is hermetic.
|
||||
_BASE_ENV_VARS = (
|
||||
"SERPER_API_BASE",
|
||||
"TAVILY_API_BASE",
|
||||
"PERPLEXITY_API_BASE",
|
||||
"APISERPENT_API_BASE",
|
||||
"EXA_API_BASE",
|
||||
"BRAVE_API_BASE",
|
||||
"FIRECRAWL_API_BASE",
|
||||
"LINKUP_API_BASE",
|
||||
"SEARCHAPI_API_BASE",
|
||||
"GOOGLE_PSE_API_BASE",
|
||||
"PARALLEL_AI_API_BASE",
|
||||
"YOUCOM_API_BASE",
|
||||
"SEARXNG_API_BASE",
|
||||
"DATAFORSEO_API_BASE",
|
||||
"TINYFISH_API_BASE",
|
||||
"CRW_API_BASE",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_base_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for var in _BASE_ENV_VARS:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
# (config, {server secret env vars}, caller_api_key honored as-is, extra env for full validate)
|
||||
ProviderSpec = Tuple[Type[BaseSearchConfig], Dict[str, str], str, Dict[str, str]]
|
||||
|
||||
PROVIDERS: Tuple[ProviderSpec, ...] = (
|
||||
(SerperSearchConfig, {"SERPER_API_KEY": "srv"}, "caller-key", {}),
|
||||
(TavilySearchConfig, {"TAVILY_API_KEY": "srv"}, "caller-key", {}),
|
||||
(PerplexitySearchConfig, {"PERPLEXITYAI_API_KEY": "srv"}, "caller-key", {}),
|
||||
(APISerpentSearchConfig, {"APISERPENT_API_KEY": "srv"}, "caller-key", {}),
|
||||
(ExaAISearchConfig, {"EXA_API_KEY": "srv"}, "caller-key", {}),
|
||||
(BraveSearchConfig, {"BRAVE_API_KEY": "srv"}, "caller-key", {}),
|
||||
(FirecrawlSearchConfig, {"FIRECRAWL_API_KEY": "srv"}, "caller-key", {}),
|
||||
(LinkupSearchConfig, {"LINKUP_API_KEY": "srv"}, "caller-key", {}),
|
||||
(SearchAPIConfig, {"SEARCHAPI_API_KEY": "srv"}, "caller-key", {}),
|
||||
(
|
||||
GooglePSESearchConfig,
|
||||
{"GOOGLE_PSE_API_KEY": "srv"},
|
||||
"caller-key",
|
||||
{"GOOGLE_PSE_ENGINE_ID": "engine"},
|
||||
),
|
||||
(ParallelAISearchConfig, {"PARALLEL_API_KEY": "srv"}, "caller-key", {}),
|
||||
(YouComSearchConfig, {"YOUCOM_API_KEY": "srv"}, "caller-key", {}),
|
||||
(SearXNGSearchConfig, {"SEARXNG_API_KEY": "srv"}, "caller-key", {}),
|
||||
(
|
||||
DataForSEOSearchConfig,
|
||||
{"DATAFORSEO_LOGIN": "srv", "DATAFORSEO_PASSWORD": "pw"},
|
||||
"login:password",
|
||||
{},
|
||||
),
|
||||
(TinyfishSearchConfig, {"TINYFISH_API_KEY": "srv"}, "caller-key", {}),
|
||||
(FastCRWSearchConfig, {"CRW_API_KEY": "srv"}, "caller-key", {}),
|
||||
)
|
||||
|
||||
_IDS = tuple(spec[0].__name__ for spec in PROVIDERS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_cls, server_env, caller_key, extra_env", PROVIDERS, ids=_IDS
|
||||
)
|
||||
def test_server_secret_refused_for_caller_api_base(
|
||||
config_cls: Type[BaseSearchConfig],
|
||||
server_env: Dict[str, str],
|
||||
caller_key: str,
|
||||
extra_env: Dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
for key, value in {**server_env, **extra_env}.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to send the server-configured"):
|
||||
config_cls().validate_environment(headers={}, api_base=ATTACKER_BASE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_cls, server_env, caller_key, extra_env", PROVIDERS, ids=_IDS
|
||||
)
|
||||
def test_caller_supplied_key_is_honored_for_custom_api_base(
|
||||
config_cls: Type[BaseSearchConfig],
|
||||
server_env: Dict[str, str],
|
||||
caller_key: str,
|
||||
extra_env: Dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
for key, value in {**server_env, **extra_env}.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
|
||||
# An explicit caller key is the caller's own credential, so pointing it at
|
||||
# the caller's own host must be allowed.
|
||||
config_cls().validate_environment(
|
||||
headers={}, api_key=caller_key, api_base=ATTACKER_BASE
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_cls, server_env, caller_key, extra_env", PROVIDERS, ids=_IDS
|
||||
)
|
||||
def test_server_secret_used_without_caller_api_base(
|
||||
config_cls: Type[BaseSearchConfig],
|
||||
server_env: Dict[str, str],
|
||||
caller_key: str,
|
||||
extra_env: Dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
for key, value in {**server_env, **extra_env}.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
|
||||
# No caller-supplied api_base -> the request targets the trusted default, so
|
||||
# the server secret is still used and nothing is refused.
|
||||
config_cls().validate_environment(headers={})
|
||||
|
||||
|
||||
def test_keyless_provider_allows_caller_api_base(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("SEARXNG_API_KEY", raising=False)
|
||||
|
||||
headers = SearXNGSearchConfig().validate_environment(
|
||||
headers={}, api_base="https://my-searxng.internal"
|
||||
)
|
||||
|
||||
assert "Authorization" not in headers
|
||||
|
||||
|
||||
def test_operator_env_base_override_is_trusted(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("SERPER_API_KEY", "srv")
|
||||
monkeypatch.setenv("SERPER_API_BASE", "https://serper.internal.corp")
|
||||
|
||||
# Mirrors the second validate_environment call in the search handler, which
|
||||
# receives the already-resolved operator base as api_base.
|
||||
headers = SerperSearchConfig().validate_environment(
|
||||
headers={}, api_base="https://serper.internal.corp/search"
|
||||
)
|
||||
|
||||
assert headers["X-API-KEY"] == "srv"
|
||||
|
||||
|
||||
class TestResolveServerApiKey:
|
||||
def test_caller_key_short_circuits(self) -> None:
|
||||
result = BaseSearchConfig().resolve_server_api_key(
|
||||
caller_api_key="mine",
|
||||
caller_api_base=ATTACKER_BASE,
|
||||
key_env_vars=("SERPER_API_KEY",),
|
||||
base_env_var="SERPER_API_BASE",
|
||||
default_api_base="https://google.serper.dev",
|
||||
)
|
||||
assert result == "mine"
|
||||
|
||||
def test_returns_none_when_no_server_secret(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv("SEARXNG_API_KEY", raising=False)
|
||||
result = BaseSearchConfig().resolve_server_api_key(
|
||||
caller_api_key=None,
|
||||
caller_api_base=ATTACKER_BASE,
|
||||
key_env_vars=("SEARXNG_API_KEY",),
|
||||
base_env_var="SEARXNG_API_BASE",
|
||||
default_api_base=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_first_set_env_var_wins(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("PARALLEL_AI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("PARALLEL_API_KEY", "second")
|
||||
result = BaseSearchConfig().resolve_server_api_key(
|
||||
caller_api_key=None,
|
||||
caller_api_base=None,
|
||||
key_env_vars=("PARALLEL_AI_API_KEY", "PARALLEL_API_KEY"),
|
||||
base_env_var="PARALLEL_AI_API_BASE",
|
||||
default_api_base="https://api.parallel.ai",
|
||||
)
|
||||
assert result == "second"
|
||||
|
||||
|
||||
class TestIsTrustedSearchApiBase:
|
||||
def test_matches_default_host(self) -> None:
|
||||
assert _is_trusted_search_api_base(
|
||||
"https://google.serper.dev/search", "https://google.serper.dev", None
|
||||
)
|
||||
|
||||
def test_foreign_host_untrusted(self) -> None:
|
||||
assert not _is_trusted_search_api_base(
|
||||
ATTACKER_BASE, "https://google.serper.dev", None
|
||||
)
|
||||
|
||||
def test_env_override_host_trusted(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("SERPER_API_BASE", "https://serper.internal.corp")
|
||||
assert _is_trusted_search_api_base(
|
||||
"https://serper.internal.corp/search",
|
||||
"https://google.serper.dev",
|
||||
"SERPER_API_BASE",
|
||||
)
|
||||
|
||||
def test_schemeless_candidate_untrusted(self) -> None:
|
||||
# Without a scheme urlsplit puts the value in the path, leaving an empty
|
||||
# netloc; an unparseable host must never be treated as trusted.
|
||||
assert not _is_trusted_search_api_base(
|
||||
"attacker.example.com", "https://google.serper.dev", None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asearch_does_not_leak_server_key_to_caller_api_base(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""End-to-end regression on the reported vector: a search call with a foreign
|
||||
api_base and no caller key must fail without any outbound request carrying the
|
||||
server-configured key."""
|
||||
monkeypatch.setenv("SERPER_API_KEY", "sk-server-secret")
|
||||
monkeypatch.delenv("SERPER_API_BASE", raising=False)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_post,
|
||||
patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get,
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
await litellm.asearch(
|
||||
query="secrets",
|
||||
search_provider="serper",
|
||||
api_base=ATTACKER_BASE,
|
||||
)
|
||||
|
||||
mock_post.assert_not_called()
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider, key_env, server_key, extra_env",
|
||||
[
|
||||
("searchapi", "SEARCHAPI_API_KEY", "sk-server-searchapi", {}),
|
||||
(
|
||||
"google_pse",
|
||||
"GOOGLE_PSE_API_KEY",
|
||||
"sk-server-google",
|
||||
{"GOOGLE_PSE_ENGINE_ID": "engine-id"},
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_param_key_not_leaked_with_dummy_caller_key(
|
||||
provider: str,
|
||||
key_env: str,
|
||||
server_key: str,
|
||||
extra_env: Dict[str, str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Providers that send the key as a URL query param resolve it in
|
||||
transform_search_request, not validate_environment. A caller who passes a
|
||||
dummy api_key to clear the validate_environment short-circuit must not cause
|
||||
the server key to be placed in the URL sent to their own api_base."""
|
||||
monkeypatch.setenv(key_env, server_key)
|
||||
for name, value in extra_env.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
|
||||
captured: Dict[str, str] = {}
|
||||
|
||||
async def fake_get(self, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||
captured["url"] = kwargs.get("url") or (args[0] if args else "")
|
||||
raise RuntimeError("stop after capturing the outbound url")
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get",
|
||||
fake_get,
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
await litellm.asearch(
|
||||
query="secrets",
|
||||
search_provider=provider,
|
||||
api_key="sk-CALLER-DUMMY",
|
||||
api_base=ATTACKER_BASE,
|
||||
)
|
||||
|
||||
assert captured["url"], "expected an outbound request to be attempted"
|
||||
assert server_key not in captured["url"]
|
||||
assert "sk-CALLER-DUMMY" in captured["url"]
|
||||
|
|
@ -293,7 +293,10 @@ class TestParallelAISearch:
|
|||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_api_base_appends_v1_search(self, api_base):
|
||||
async def test_custom_api_base_appends_v1_search(self, api_base, monkeypatch):
|
||||
# Operator points at an internal base via the env override (a trusted
|
||||
# host), so the server key is still used and the URL is normalized.
|
||||
monkeypatch.setenv("PARALLEL_AI_API_BASE", api_base)
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
||||
new_callable=AsyncMock,
|
||||
|
|
@ -303,7 +306,6 @@ class TestParallelAISearch:
|
|||
await litellm.asearch(
|
||||
query="AI developments",
|
||||
search_provider="parallel_ai",
|
||||
api_base=api_base,
|
||||
)
|
||||
|
||||
call_args = mock_post.call_args
|
||||
|
|
@ -312,6 +314,23 @@ class TestParallelAISearch:
|
|||
== "https://proxy.internal.example.com/v1/search"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_caller_api_base_without_key_is_refused(self, monkeypatch):
|
||||
# A caller-supplied api_base (untrusted host) while relying on the
|
||||
# server key must be refused without any outbound request.
|
||||
monkeypatch.setenv("PARALLEL_API_KEY", "server-secret")
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_post:
|
||||
with pytest.raises(Exception, match="Refusing to send"):
|
||||
await litellm.asearch(
|
||||
query="AI developments",
|
||||
search_provider="parallel_ai",
|
||||
api_base="https://attacker.example.com",
|
||||
)
|
||||
mock_post.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_api_key_raises(self, monkeypatch):
|
||||
monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
|
||||
|
|
|
|||
|
|
@ -63,23 +63,17 @@ class TestTinyfishSearchConfig:
|
|||
assert headers["X-API-Key"] == "sk-tinyfish-test"
|
||||
assert headers["Accept"] == "application/json"
|
||||
|
||||
def test_validate_environment_from_env(self):
|
||||
def test_validate_environment_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("TINYFISH_API_KEY", "sk-from-env")
|
||||
config = TinyfishSearchConfig()
|
||||
with patch(
|
||||
"litellm.llms.tinyfish.search.transformation.get_secret_str",
|
||||
return_value="sk-from-env",
|
||||
):
|
||||
headers = config.validate_environment(headers={})
|
||||
headers = config.validate_environment(headers={})
|
||||
assert headers["X-API-Key"] == "sk-from-env"
|
||||
|
||||
def test_validate_environment_missing_key(self):
|
||||
def test_validate_environment_missing_key(self, monkeypatch):
|
||||
monkeypatch.delenv("TINYFISH_API_KEY", raising=False)
|
||||
config = TinyfishSearchConfig()
|
||||
with patch(
|
||||
"litellm.llms.tinyfish.search.transformation.get_secret_str",
|
||||
return_value=None,
|
||||
):
|
||||
with pytest.raises(ValueError, match="TINYFISH_API_KEY"):
|
||||
config.validate_environment(headers={})
|
||||
with pytest.raises(ValueError, match="TINYFISH_API_KEY"):
|
||||
config.validate_environment(headers={})
|
||||
|
||||
def test_validate_environment_uses_api_base_kwarg(self):
|
||||
config = TinyfishSearchConfig()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue