mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
fix(anthropic): stop caller keys riding upstream beside a server-minted passthrough token
The Anthropic passthrough route forwards the caller's headers, so once the server owned the credential the caller's own key travelled to Anthropic alongside the minted bearer. Two credentials arrived in one request and the caller's could win, which defeats the point of server-owned federation The set of headers the server owns is now derived from SpecialHeaders, the same enumeration the proxy accepts a LiteLLM key in, plus the configured litellm_key_header_name, rather than the two names it listed before. That closes the same leak for x-litellm-api-key, api-key, x-goog-api-key and Ocp-Apim-Subscription-Key. The non-passthrough path already stripped these, so this brings the two into line Bring-your-own-key is untouched: with no server credential the caller's key is the only one there is, so it still forwards
This commit is contained in:
parent
6c89af0720
commit
c9063a2e3b
4 changed files with 317 additions and 12 deletions
|
|
@ -23,6 +23,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
|
|||
from litellm.llms.anthropic.wif import aget_anthropic_wif_token, get_anthropic_wif_token
|
||||
from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.proxy._types import SpecialHeaders
|
||||
from litellm.types.llms.anthropic import (
|
||||
ANTHROPIC_HOSTED_TOOLS,
|
||||
ANTHROPIC_OAUTH_BETA_HEADER,
|
||||
|
|
@ -57,7 +58,7 @@ def _strip_bedrock_id_suffixes(model: str) -> str:
|
|||
)
|
||||
|
||||
|
||||
_SERVER_OWNED_AUTH_HEADERS: Final = frozenset({"x-api-key", "authorization"})
|
||||
_SERVER_OWNED_AUTH_HEADERS: Final = SpecialHeaders.litellm_credential_header_names()
|
||||
_WIF_ELIGIBILITY_ATTR: Final = "_workload_identity_eligible"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,11 @@ from litellm.constants import (
|
|||
ALLOWED_VERTEX_AI_PASSTHROUGH_HEADERS,
|
||||
BEDROCK_AGENT_RUNTIME_PASS_THROUGH_ROUTES,
|
||||
)
|
||||
from litellm.llms.anthropic.common_utils import AnthropicModelInfo, merge_anthropic_beta_headers
|
||||
from litellm.llms.anthropic.common_utils import (
|
||||
_SERVER_OWNED_AUTH_HEADERS, # pyright: ignore[reportPrivateUsage] # canonical set, must not be duplicated here
|
||||
AnthropicModelInfo,
|
||||
merge_anthropic_beta_headers,
|
||||
)
|
||||
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
|
|
@ -579,6 +583,46 @@ def _anthropic_passthrough_headers(auth_header: Mapping[str, str] | None, client
|
|||
return MappingProxyType({**auth_header, "anthropic-beta": merge_anthropic_beta_headers(client_beta, auth_beta)})
|
||||
|
||||
|
||||
def _configured_litellm_key_header_name() -> str | None:
|
||||
from litellm.proxy.proxy_server import (
|
||||
general_settings, # pyright: ignore[reportUnknownVariableType] # proxy_server declares it as a bare dict
|
||||
)
|
||||
|
||||
configured: Final = general_settings.get( # pyright: ignore[reportUnknownMemberType] # proxy_server general_settings is a bare dict
|
||||
"litellm_key_header_name"
|
||||
)
|
||||
return configured if isinstance(configured, str) else None
|
||||
|
||||
|
||||
def _anthropic_passthrough_header_plan(
|
||||
request: Request, auth_header: Mapping[str, str] | None, litellm_key_header_name: str | None
|
||||
) -> tuple[Mapping[str, str], bool]:
|
||||
"""Returns the headers to send upstream plus whether the relay should still forward the
|
||||
caller's own headers. Once the server owns the Anthropic credential, none of the headers
|
||||
the proxy accepts a LiteLLM key in (``SpecialHeaders`` plus the configured custom name)
|
||||
may ride upstream beside it, so the forward merge runs here with those stripped and the
|
||||
relay is told not to merge again. With no server credential the caller's key is the only
|
||||
one there is, so forwarding stays on (BYOK)."""
|
||||
server_headers: Final = _anthropic_passthrough_headers(auth_header, request.headers.get("anthropic-beta"))
|
||||
if auth_header is None:
|
||||
return server_headers, True
|
||||
caller_owned: Final = _SERVER_OWNED_AUTH_HEADERS | frozenset(
|
||||
(litellm_key_header_name.lower(),) if litellm_key_header_name else ()
|
||||
)
|
||||
caller_headers: Final[dict[str, str]] = { # mutable-ok: forward_headers_from_request takes a concrete dict
|
||||
name: value for name, value in request.headers.items() if name.lower() not in caller_owned
|
||||
}
|
||||
merged: Final = cast( # cast-ok: forward_headers_from_request is untyped upstream, its result is a header dict
|
||||
"dict[str, str]",
|
||||
HttpPassThroughEndpointHelpers.forward_headers_from_request( # pyright: ignore[reportUnknownMemberType] # untyped upstream
|
||||
request_headers=caller_headers,
|
||||
headers=dict(server_headers), # mutable-ok: forward_headers_from_request takes a concrete dict
|
||||
forward_headers=True,
|
||||
),
|
||||
)
|
||||
return MappingProxyType(merged), False
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/anthropic/{endpoint:path}",
|
||||
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
|
|
@ -620,11 +664,14 @@ async def anthropic_proxy_route(
|
|||
auth_header: Final = await AnthropicModelInfo.aget_auth_header(
|
||||
anthropic_api_key or None, allow_workload_identity=True
|
||||
)
|
||||
upstream_headers, forward_caller_headers = _anthropic_passthrough_header_plan(
|
||||
request, auth_header, _configured_litellm_key_header_name()
|
||||
)
|
||||
endpoint_func: Final = create_pass_through_route(
|
||||
endpoint=endpoint,
|
||||
target=str(updated_url),
|
||||
custom_headers=_anthropic_passthrough_headers(auth_header, request.headers.get("anthropic-beta")),
|
||||
_forward_headers=True,
|
||||
custom_headers=upstream_headers,
|
||||
_forward_headers=forward_caller_headers,
|
||||
is_streaming_request=is_streaming_request,
|
||||
) # dynamically construct pass-through endpoint based on incoming path
|
||||
received_value: Final = await endpoint_func(
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ from unittest.mock import patch
|
|||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../.."))
|
||||
)
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..")))
|
||||
|
||||
from litellm.proxy._types import SpecialHeaders # noqa: E402 # sys.path must be patched before importing litellm
|
||||
|
||||
# Fake tokens for testing (not real secrets)
|
||||
FAKE_OAUTH_TOKEN = "sk-ant-oat01-fake-token-for-testing-123456789abcdef"
|
||||
|
|
@ -2069,6 +2069,8 @@ WIF_ENV = {
|
|||
"ANTHROPIC_IDENTITY_TOKEN": "inline-wire-jwt",
|
||||
}
|
||||
|
||||
PROXY_CREDENTIAL_HEADER_NAMES = sorted(SpecialHeaders.litellm_credential_header_names())
|
||||
|
||||
|
||||
class RecordingPoster:
|
||||
def __init__(self, response):
|
||||
|
|
@ -2281,6 +2283,39 @@ class TestWifServerOwnedAuthHeaderStrip:
|
|||
assert caller_key not in headers.values()
|
||||
assert len(poster.requests) == 1
|
||||
|
||||
def test_server_owned_set_is_every_proxy_credential_header(self):
|
||||
"""The strip list must track the proxy's own key-header list, not a hand-rolled
|
||||
pair: every header user_api_key_auth accepts a LiteLLM key in must be here."""
|
||||
from litellm.llms.anthropic.common_utils import _SERVER_OWNED_AUTH_HEADERS
|
||||
|
||||
assert _SERVER_OWNED_AUTH_HEADERS == SpecialHeaders.litellm_credential_header_names()
|
||||
assert {"x-litellm-api-key", "api-key", "x-goog-api-key"} < _SERVER_OWNED_AUTH_HEADERS
|
||||
|
||||
@pytest.mark.parametrize("header_name", PROXY_CREDENTIAL_HEADER_NAMES)
|
||||
def test_mint_strips_every_proxy_credential_header(self, monkeypatch, wif_engine, header_name):
|
||||
"""A LiteLLM virtual key arrives in any of the proxy's accepted key headers; once
|
||||
a mint happened none of them may reach Anthropic in any header slot."""
|
||||
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
|
||||
|
||||
for name, value in WIF_ENV.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
caller_key = "sk-litellm-CALLER-VIRTUAL-KEY"
|
||||
|
||||
headers = AnthropicModelInfo().validate_environment(
|
||||
headers={header_name.title(): caller_key, "user-agent": "caller/1.0"},
|
||||
model="claude-sonnet-4-5",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
api_key=None,
|
||||
api_base=None,
|
||||
)
|
||||
|
||||
assert headers["authorization"] == f"Bearer {FAKE_MINTED_TOKEN}"
|
||||
assert header_name == "authorization" or header_name not in {name.lower() for name in headers}
|
||||
assert all(caller_key not in value for value in headers.values())
|
||||
assert headers["user-agent"] == "caller/1.0"
|
||||
|
||||
def test_no_mint_preserves_caller_supplied_authorization(self, monkeypatch, clean_anthropic_env):
|
||||
"""No-regression: LiteLLM deliberately lets a caller-forwarded credential
|
||||
header ride alongside a statically configured ANTHROPIC_API_KEY, because the
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
|
|||
vertex_proxy_route,
|
||||
vllm_proxy_route,
|
||||
)
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy._types import LitellmUserRoles, SpecialHeaders, UserAPIKeyAuth
|
||||
from litellm.types.passthrough_endpoints.vertex_ai import VertexPassThroughCredentials
|
||||
|
||||
|
||||
|
|
@ -2345,7 +2345,6 @@ class TestMilvusProxyRoute:
|
|||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_response = MagicMock(spec=Response)
|
||||
mock_user_api_key_dict = MagicMock()
|
||||
|
|
@ -2379,7 +2378,6 @@ class TestMilvusProxyRoute:
|
|||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_response = MagicMock(spec=Response)
|
||||
mock_user_api_key_dict = MagicMock()
|
||||
|
|
@ -2406,7 +2404,6 @@ class TestMilvusProxyRoute:
|
|||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
collection_name = "test-collection"
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
|
|
@ -2443,7 +2440,6 @@ class TestMilvusProxyRoute:
|
|||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
collection_name = "unmanaged-collection"
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
|
|
@ -3946,3 +3942,229 @@ class TestAnthropicProxyRoute:
|
|||
assert custom_headers["anthropic-beta"] == "oauth-2025-04-20"
|
||||
assert sync_calls == []
|
||||
assert thread_ids and thread_ids[0] != threading.get_ident()
|
||||
|
||||
|
||||
class TestAnthropicProxyRouteCallerAuthHeaders:
|
||||
"""Regression for a caller credential riding upstream next to a server-owned one.
|
||||
|
||||
/anthropic forwards the caller's headers, so a caller-supplied ``x-api-key`` used to reach
|
||||
Anthropic alongside the server-minted ``Authorization: Bearer``. These drive the real relay
|
||||
(only the httpx client is stubbed) and assert on the bytes actually handed to the upstream.
|
||||
"""
|
||||
|
||||
_MINTED: Final = "sk-ant-oat01-plan-minted"
|
||||
|
||||
def _clear_anthropic_env(self, monkeypatch) -> None:
|
||||
for name in (
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_BASE",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_FEDERATION_RULE_ID",
|
||||
"ANTHROPIC_ORGANIZATION_ID",
|
||||
"ANTHROPIC_IDENTITY_TOKEN_FILE",
|
||||
"ANTHROPIC_IDENTITY_TOKEN",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
def _enable_wif(self, monkeypatch) -> None:
|
||||
from litellm.llms.anthropic import common_utils as anthropic_common_utils
|
||||
from litellm.llms.anthropic.wif import aget_anthropic_wif_token
|
||||
from litellm.llms.base_llm.auth.token_exchange import JwtBearerTokenExchangeEngine
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_FEDERATION_RULE_ID", "fdrl_plan")
|
||||
monkeypatch.setenv("ANTHROPIC_ORGANIZATION_ID", "org-plan")
|
||||
monkeypatch.setenv("ANTHROPIC_IDENTITY_TOKEN", "plan-inline-jwt")
|
||||
|
||||
minted: Final = self._MINTED
|
||||
|
||||
class StubPoster:
|
||||
def post(self, url, *, content, headers, timeout):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"access_token": minted, "token_type": "Bearer", "expires_in": 3600},
|
||||
)
|
||||
|
||||
engine: Final = JwtBearerTokenExchangeEngine(poster=StubPoster())
|
||||
|
||||
async def async_shim(litellm_params, api_base, model):
|
||||
return await aget_anthropic_wif_token(litellm_params, api_base, model, engine)
|
||||
|
||||
monkeypatch.setattr(anthropic_common_utils, "aget_anthropic_wif_token", async_shim)
|
||||
|
||||
def _request(self, headers: Mapping[str, str]) -> Request:
|
||||
body: Final = b'{"model":"claude-sonnet-4-5","messages":[]}'
|
||||
scope: Final = {
|
||||
"type": "http",
|
||||
"http_version": "1.1",
|
||||
"method": "POST",
|
||||
"scheme": "https",
|
||||
"path": "/anthropic/v1/messages",
|
||||
"raw_path": b"/anthropic/v1/messages",
|
||||
"root_path": "",
|
||||
"query_string": b"",
|
||||
"headers": [(name.lower().encode(), value.encode()) for name, value in headers.items()],
|
||||
"client": ("127.0.0.1", 51234),
|
||||
"server": ("proxy.local", 4000),
|
||||
"state": {},
|
||||
}
|
||||
|
||||
async def receive() -> dict:
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
return Request(scope, receive)
|
||||
|
||||
async def _upstream_headers(self, request: Request) -> dict:
|
||||
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
|
||||
anthropic_proxy_route,
|
||||
)
|
||||
|
||||
upstream_response: Final = MagicMock()
|
||||
upstream_response.status_code = 200
|
||||
upstream_response.headers = {"content-type": "application/json"}
|
||||
upstream_response.aread = AsyncMock(return_value=b'{"ok": true}')
|
||||
upstream_response.aiter_bytes = AsyncMock(return_value=[b'{"ok": true}'])
|
||||
|
||||
httpx_client: Final = MagicMock()
|
||||
httpx_client.build_request = MagicMock(return_value=MagicMock())
|
||||
httpx_client.send = AsyncMock(return_value=upstream_response)
|
||||
client_wrapper: Final = MagicMock()
|
||||
client_wrapper.client = httpx_client
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_async_httpx_client",
|
||||
return_value=client_wrapper,
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj") as mock_logging_obj,
|
||||
):
|
||||
mock_logging_obj.pre_call_hook = AsyncMock(return_value={"model": "claude-sonnet-4-5", "messages": []})
|
||||
mock_logging_obj.post_call_success_hook = AsyncMock()
|
||||
mock_logging_obj.post_call_failure_hook = AsyncMock()
|
||||
mock_logging_obj.post_call_response_headers_hook = AsyncMock(return_value={})
|
||||
|
||||
await anthropic_proxy_route(
|
||||
endpoint="v1/messages",
|
||||
request=request,
|
||||
fastapi_response=MagicMock(spec=Response),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="sk-caller-virtual-key"),
|
||||
)
|
||||
|
||||
assert httpx_client.send.called
|
||||
return {name.lower(): value for name, value in dict(httpx_client.build_request.call_args[1]["headers"]).items()}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wif_credential_drops_caller_supplied_api_key(self, monkeypatch):
|
||||
self._clear_anthropic_env(monkeypatch)
|
||||
self._enable_wif(monkeypatch)
|
||||
|
||||
sent: Final = await self._upstream_headers(
|
||||
self._request(
|
||||
{
|
||||
"content-type": "application/json",
|
||||
"x-api-key": "sk-caller-virtual-key",
|
||||
"user-agent": "caller/1.0",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert sent["authorization"] == f"Bearer {self._MINTED}"
|
||||
assert "x-api-key" not in sent
|
||||
assert sent["user-agent"] == "caller/1.0"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wif_credential_drops_caller_supplied_authorization(self, monkeypatch):
|
||||
self._clear_anthropic_env(monkeypatch)
|
||||
self._enable_wif(monkeypatch)
|
||||
|
||||
sent: Final = await self._upstream_headers(
|
||||
self._request(
|
||||
{
|
||||
"content-type": "application/json",
|
||||
"authorization": "Bearer sk-caller-virtual-key",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert sent["authorization"] == f"Bearer {self._MINTED}"
|
||||
assert all("sk-caller-virtual-key" not in value for value in sent.values())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("header_name", sorted(SpecialHeaders.litellm_credential_header_names()))
|
||||
async def test_wif_credential_drops_every_proxy_key_header(self, monkeypatch, header_name: str):
|
||||
"""The proxy accepts a LiteLLM key in any SpecialHeaders slot, so the caller's virtual
|
||||
key must not reach Anthropic from any of them once the server owns the credential."""
|
||||
self._clear_anthropic_env(monkeypatch)
|
||||
self._enable_wif(monkeypatch)
|
||||
|
||||
sent: Final = await self._upstream_headers(
|
||||
self._request(
|
||||
{
|
||||
"content-type": "application/json",
|
||||
header_name: "sk-caller-virtual-key",
|
||||
"user-agent": "caller/1.0",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert sent["authorization"] == f"Bearer {self._MINTED}"
|
||||
assert header_name == "authorization" or header_name not in sent
|
||||
assert all("sk-caller-virtual-key" not in value for value in sent.values())
|
||||
assert sent["user-agent"] == "caller/1.0"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wif_credential_drops_configured_custom_key_header(self, monkeypatch):
|
||||
self._clear_anthropic_env(monkeypatch)
|
||||
self._enable_wif(monkeypatch)
|
||||
|
||||
with patch.dict("litellm.proxy.proxy_server.general_settings", {"litellm_key_header_name": "X-Tenant-Key"}):
|
||||
sent: Final = await self._upstream_headers(
|
||||
self._request(
|
||||
{
|
||||
"content-type": "application/json",
|
||||
"x-tenant-key": "sk-caller-virtual-key",
|
||||
"x-tenant-region": "eu",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert sent["authorization"] == f"Bearer {self._MINTED}"
|
||||
assert "x-tenant-key" not in sent
|
||||
assert all("sk-caller-virtual-key" not in value for value in sent.values())
|
||||
assert sent["x-tenant-region"] == "eu"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_api_key_drops_caller_supplied_authorization(self, monkeypatch):
|
||||
self._clear_anthropic_env(monkeypatch)
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-server-owned")
|
||||
|
||||
sent: Final = await self._upstream_headers(
|
||||
self._request(
|
||||
{
|
||||
"content-type": "application/json",
|
||||
"authorization": "Bearer sk-caller-virtual-key",
|
||||
"x-api-key": "sk-caller-virtual-key",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert sent["x-api-key"] == "sk-ant-server-owned"
|
||||
assert "authorization" not in sent
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_byok_caller_key_still_reaches_upstream(self, monkeypatch):
|
||||
self._clear_anthropic_env(monkeypatch)
|
||||
|
||||
sent: Final = await self._upstream_headers(
|
||||
self._request(
|
||||
{
|
||||
"content-type": "application/json",
|
||||
"x-api-key": "sk-ant-caller-owned",
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert sent["x-api-key"] == "sk-ant-caller-owned"
|
||||
assert sent["anthropic-version"] == "2023-06-01"
|
||||
assert "authorization" not in sent
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue