Merge pull request #41340 from BerriAI/litellm_anthropic_passthrough_strip_virtual_key

fix(proxy): never forward the LiteLLM virtual key to Anthropic on the /anthropic passthrough
This commit is contained in:
Mateo Wang 2026-09-16 13:12:42 -07:00 committed by GitHub
commit 365c6de875
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 385 additions and 22 deletions

View file

@ -2571,6 +2571,13 @@ def _token_can_vouch_for_team(valid_token: UserAPIKeyAuth, lookup_error: BaseExc
return PrismaDBExceptionHandler.should_allow_request_on_db_unavailable()
def is_no_auth_dev_mode(master_key: str | None, general_settings: Mapping[str, object]) -> bool:
return master_key is None and not any(
general_settings.get(flag, False)
for flag in ("enable_jwt_auth", "enable_oauth2_auth", "enable_oauth2_proxy_auth")
)
@tracer.wrap()
async def _run_centralized_common_checks(
user_api_key_auth_obj: UserAPIKeyAuth,
@ -2630,11 +2637,7 @@ async def _run_centralized_common_checks(
# Running common_checks would block every admin route on these
# deployments where that was previously not the contract. If any
# authn is enabled (JWT, OAuth2, OAuth2-proxy), authz must run.
if master_key is None and not (
general_settings.get("enable_jwt_auth", False)
or general_settings.get("enable_oauth2_auth", False)
or general_settings.get("enable_oauth2_proxy_auth", False)
):
if is_no_auth_dev_mode(master_key, general_settings):
return
if user_custom_auth is not None and not general_settings.get("custom_auth_run_common_checks", False):

View file

@ -45,6 +45,7 @@ from litellm.proxy.auth.handle_jwt import JWTHandler
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.auth.user_api_key_auth import (
_get_bearer_token,
is_no_auth_dev_mode,
user_api_key_auth,
user_api_key_auth_websocket,
)
@ -709,8 +710,7 @@ async def anthropic_proxy_route(
endpoint_func: Final = create_pass_through_route(
endpoint=endpoint,
target=str(updated_url),
custom_headers=auth_header if auth_header is not None else {},
_forward_headers=True,
custom_headers=_upstream_headers_for_anthropic_route(request, user_api_key_dict, auth_header),
is_streaming_request=is_streaming_request,
) # dynamically construct pass-through endpoint based on incoming path
received_value: Final = await endpoint_func(
@ -1989,6 +1989,19 @@ _HEADERS_NEVER_FORWARDED_TO_VERTEX: Final = frozenset({"content-length", "host"}
SpecialHeaders.litellm_credential_header_names() - _VERTEX_UPSTREAM_CREDENTIAL_HEADERS
)
_CREDENTIALLESS_ANTHROPIC_MISSING_CREDENTIAL_DETAIL: Final = (
"No Anthropic credential is configured on this proxy and the request carried no upstream "
"Anthropic credential. The LiteLLM virtual key is not forwarded to Anthropic. Configure an "
"Anthropic credential (ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN, or a model with "
"use_in_pass_through: true), or send your own Anthropic API key in the x-api-key header or "
"your own Anthropic OAuth token in the Authorization header."
)
_ANTHROPIC_UPSTREAM_CREDENTIAL_HEADERS: Final = frozenset({"authorization", "x-api-key"})
_HEADERS_NEVER_FORWARDED_TO_ANTHROPIC: Final = frozenset({"content-length", "host", "accept-encoding"}) | (
SpecialHeaders.litellm_credential_header_names() - _ANTHROPIC_UPSTREAM_CREDENTIAL_HEADERS
)
_MAPPED_ROUTE_CALLER_KEY_HEADER: Final = "litellm_user_api_key"
@ -2026,8 +2039,11 @@ def _is_authenticated_caller_jwt(value: str, jwt_claims: Mapping[str, object]) -
def _is_authenticated_caller_secret(value: str, user_api_key_dict: UserAPIKeyAuth) -> bool:
"""Whether a header value is the master key, the JWT that authenticated, or the key stored as ``api_key``."""
from litellm.proxy.proxy_server import master_key
"""Whether a header value is the master key, the JWT that authenticated, or the key stored as ``api_key``.
A proxy in no-auth dev mode without custom auth authenticated nothing, so none of the caller's values is one.
"""
from litellm.proxy.proxy_server import general_settings, master_key, user_custom_auth
normalized: Final = _normalize_credential_value(value)
if master_key is not None and hmac.compare_digest(normalized.encode(), master_key.encode()):
@ -2035,35 +2051,54 @@ def _is_authenticated_caller_secret(value: str, user_api_key_dict: UserAPIKeyAut
jwt_claims: Final = user_api_key_dict.jwt_claims
if jwt_claims and _is_authenticated_caller_jwt(normalized, jwt_claims):
return True
if is_no_auth_dev_mode(master_key, general_settings) and user_custom_auth is None:
return False
authenticated_key: Final = user_api_key_dict.api_key
if authenticated_key is None:
return False
if master_key is None and not normalized.startswith("sk-"):
return False
stored_representation: Final = UserAPIKeyAuth._safe_hash_litellm_api_key(normalized) # pyright: ignore[reportPrivateUsage] # the exact transform auth applied when it stored api_key
return hmac.compare_digest(stored_representation.encode(), authenticated_key.encode())
def _caller_headers_without_litellm_secrets(
request: Request, user_api_key_dict: UserAPIKeyAuth, never_forwarded: frozenset[str]
) -> Mapping[str, str]:
incoming: Final = _safe_get_request_headers(request)
dropped_by_name: Final = never_forwarded.union(
(_MAPPED_ROUTE_CALLER_KEY_HEADER, *_operator_configured_caller_key_header_names())
)
return MappingProxyType(
{
name: value
for name, value in incoming.items()
if name not in dropped_by_name and not _is_authenticated_caller_secret(value, user_api_key_dict)
}
)
def _forwarded_headers_for_credentialless_vertex_passthrough(
request: Request, user_api_key_dict: UserAPIKeyAuth
) -> Mapping[str, str]:
"""Caller headers to forward on the bring-your-own-credentials Vertex branch, minus LiteLLM secrets."""
incoming: Final = _safe_get_request_headers(request)
never_forwarded: Final = _HEADERS_NEVER_FORWARDED_TO_VERTEX.union(
(_MAPPED_ROUTE_CALLER_KEY_HEADER, *_operator_configured_caller_key_header_names())
forwarded: Final = _caller_headers_without_litellm_secrets(
request, user_api_key_dict, _HEADERS_NEVER_FORWARDED_TO_VERTEX
)
forwarded: Final = MappingProxyType(
{
name: value
for name, value in incoming.items()
if name not in never_forwarded and not _is_authenticated_caller_secret(value, user_api_key_dict)
}
)
if "authorization" not in forwarded and "x-goog-api-key" not in forwarded:
if _VERTEX_UPSTREAM_CREDENTIAL_HEADERS.isdisjoint(forwarded):
raise HTTPException(status_code=401, detail=_CREDENTIALLESS_VERTEX_MISSING_CREDENTIAL_DETAIL)
return forwarded
def _upstream_headers_for_anthropic_route(
request: Request, user_api_key_dict: UserAPIKeyAuth, proxy_auth_header: Mapping[str, str] | None
) -> Mapping[str, str]:
caller_headers: Final = _caller_headers_without_litellm_secrets(
request, user_api_key_dict, _HEADERS_NEVER_FORWARDED_TO_ANTHROPIC
)
if proxy_auth_header is None and _ANTHROPIC_UPSTREAM_CREDENTIAL_HEADERS.isdisjoint(caller_headers):
raise HTTPException(status_code=401, detail=_CREDENTIALLESS_ANTHROPIC_MISSING_CREDENTIAL_DETAIL)
return MappingProxyType({**caller_headers, **(proxy_auth_header or {})})
async def _prepare_vertex_auth_headers(
request: Request,
vertex_credentials: VertexPassThroughCredentials | None,

View file

@ -28,6 +28,7 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
BaseOpenAIPassThroughHandler,
RouteChecks,
_join_url_paths,
anthropic_proxy_route,
azure_proxy_route,
bedrock_llm_proxy_route,
bedrock_proxy_route,
@ -585,6 +586,7 @@ class TestVertexAIPassThroughHandler:
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router",
pass_through_router,
)
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-master-1234")
endpoint = f"/v1/projects/{test_project}/locations/{test_location}/publishers/google/models/gemini-1.5-flash:generateContent"
@ -4286,6 +4288,329 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak:
assert "sk-master-1234" not in " ".join(f"{name}:{value}" for name, value in forwarded.items())
class TestAnthropicPassthroughVirtualKeyLeak:
VKEY = "sk-litellm-victim-key"
PROXY_KEY = "sk-ant-api03-proxy-configured-key"
ENDPOINT = "v1/messages"
async def _run(
self,
monkeypatch,
headers: list[tuple[bytes, bytes]],
authenticated: UserAPIKeyAuth | None = None,
master_key: str | None = "sk-master-1234",
proxy_api_key: str | None = None,
) -> tuple[HTTPException | None, dict | None]:
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import HttpPassThroughEndpointHelpers
from litellm.proxy.pass_through_endpoints.passthrough_endpoint_router import (
PassthroughEndpointRouter,
)
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", master_key)
monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False)
if proxy_api_key is None:
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
else:
monkeypatch.setenv("ANTHROPIC_API_KEY", proxy_api_key)
caller: Final = authenticated if authenticated is not None else UserAPIKeyAuth(api_key=self.VKEY)
async def receive():
return {"type": "http.request", "body": b"{}", "more_body": False}
request = Request(
{
"type": "http",
"method": "POST",
"path": f"/anthropic/{self.ENDPOINT}",
"headers": headers,
"query_string": b"",
},
receive=receive,
)
captured: dict = {}
def fake_create_pass_through_route(**kwargs):
captured.update(kwargs)
return AsyncMock(return_value={"status": "success"})
module = "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints"
monkeypatch.setattr(f"{module}.passthrough_endpoint_router", PassthroughEndpointRouter(lambda: None))
raised: HTTPException | None = None
with (
mock.patch(f"{module}.create_pass_through_route", side_effect=fake_create_pass_through_route),
mock.patch(f"{module}.user_api_key_auth", new=AsyncMock(return_value=caller)),
):
try:
await anthropic_proxy_route(
endpoint=self.ENDPOINT,
request=request,
fastapi_response=Response(),
user_api_key_dict=caller,
)
except HTTPException as exc:
raised = exc
if not captured:
return raised, None
upstream: Final = HttpPassThroughEndpointHelpers.forward_headers_from_request(
request_headers=dict(request.headers),
headers=dict(captured["custom_headers"] or {}),
forward_headers=captured.get("_forward_headers", False),
)
return raised, upstream
@staticmethod
def _blob(forwarded: dict) -> str:
return " ".join(f"{name}:{value}" for name, value in forwarded.items())
@pytest.mark.asyncio
async def test_authorization_bearer_virtual_key_is_rejected_not_forwarded(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[(b"authorization", f"Bearer {self.VKEY}".encode()), (b"content-type", b"application/json")],
)
assert forwarded is None, "credential-less request must never reach the upstream forwarder"
assert raised is not None and raised.status_code == 401
assert "ANTHROPIC_API_KEY" in str(raised.detail) and "use_in_pass_through" in str(raised.detail)
@pytest.mark.asyncio
async def test_x_api_key_virtual_key_is_rejected_not_forwarded(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[(b"x-api-key", self.VKEY.encode()), (b"content-type", b"application/json")],
)
assert forwarded is None, "a virtual key that authenticated via x-api-key must be stripped, not forwarded"
assert raised is not None and raised.status_code == 401
@pytest.mark.asyncio
async def test_x_litellm_api_key_virtual_key_is_rejected_not_forwarded(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[(b"x-litellm-api-key", self.VKEY.encode()), (b"content-type", b"application/json")],
)
assert forwarded is None, "credential-less request must never reach the upstream forwarder"
assert raised is not None and raised.status_code == 401
@pytest.mark.asyncio
async def test_master_key_in_authorization_is_rejected_not_forwarded(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[(b"authorization", b"Bearer sk-master-1234"), (b"content-type", b"application/json")],
authenticated=UserAPIKeyAuth(api_key="sk-master-1234", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert forwarded is None, "the master key must never reach Anthropic"
assert raised is not None and raised.status_code == 401
@pytest.mark.asyncio
@pytest.mark.parametrize(
("header", "value"),
[
pytest.param(b"x-api-key", b"sk-ant-api03-callers-own-key", id="x-api-key"),
pytest.param(b"authorization", b"Bearer sk-ant-api03-callers-own-key", id="authorization"),
],
)
async def test_without_a_master_key_the_callers_own_anthropic_key_still_forwards(
self, monkeypatch, header: bytes, value: bytes
):
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
monkeypatch.setattr("litellm.proxy.proxy_server.user_custom_auth", None)
raised, forwarded = await self._run(
monkeypatch,
[(header, value), (b"anthropic-version", b"2023-06-01"), (b"content-type", b"application/json")],
authenticated=UserAPIKeyAuth(api_key="sk-ant-api03-callers-own-key", user_role=LitellmUserRoles.INTERNAL_USER),
master_key=None,
)
assert raised is None, "with no master key the proxy authenticated nothing, so nothing of the caller's is a LiteLLM secret"
assert forwarded is not None
assert forwarded.get(header.decode()) == value.decode()
@pytest.mark.asyncio
async def test_without_a_master_key_a_custom_auth_credential_is_still_stripped(self, monkeypatch):
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
monkeypatch.setattr("litellm.proxy.proxy_server.user_custom_auth", AsyncMock())
raised, forwarded = await self._run(
monkeypatch,
[(b"authorization", b"Bearer sk-custom-auth-token"), (b"anthropic-version", b"2023-06-01")],
authenticated=UserAPIKeyAuth(api_key="sk-custom-auth-token", user_role=LitellmUserRoles.INTERNAL_USER),
master_key=None,
)
assert raised is not None and raised.status_code == 401
assert forwarded is None
@pytest.mark.asyncio
async def test_without_a_master_key_an_oauth2_token_is_still_stripped(self, monkeypatch):
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"enable_oauth2_auth": True})
monkeypatch.setattr("litellm.proxy.proxy_server.user_custom_auth", None)
raised, forwarded = await self._run(
monkeypatch,
[(b"authorization", b"Bearer oauth2-access-token"), (b"anthropic-version", b"2023-06-01")],
authenticated=UserAPIKeyAuth(api_key="oauth2-access-token", user_role=LitellmUserRoles.INTERNAL_USER),
master_key=None,
)
assert raised is not None and raised.status_code == 401
assert forwarded is None
@pytest.mark.asyncio
async def test_byo_anthropic_oauth_token_still_forwards_without_virtual_key(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-litellm-api-key", self.VKEY.encode()),
(b"authorization", b"Bearer sk-ant-oat01-caller-oauth-token"),
(b"anthropic-version", b"2023-06-01"),
(b"content-type", b"application/json"),
],
)
assert raised is None
assert forwarded is not None
assert forwarded.get("authorization") == "Bearer sk-ant-oat01-caller-oauth-token"
assert forwarded.get("anthropic-version") == "2023-06-01"
assert "x-litellm-api-key" not in forwarded
assert self.VKEY not in self._blob(forwarded)
@pytest.mark.asyncio
async def test_byo_x_api_key_still_forwards_without_virtual_key(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[
(b"authorization", f"Bearer {self.VKEY}".encode()),
(b"x-api-key", b"sk-ant-api03-caller-own-key"),
(b"content-type", b"application/json"),
],
)
assert raised is None
assert forwarded is not None
assert forwarded.get("x-api-key") == "sk-ant-api03-caller-own-key"
assert "authorization" not in forwarded
assert self.VKEY not in self._blob(forwarded)
@pytest.mark.asyncio
async def test_custom_auth_caller_keeps_own_authorization_token(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[(b"authorization", b"Bearer sk-ant-oat01-caller-oauth-token"), (b"content-type", b"application/json")],
authenticated=UserAPIKeyAuth(api_key=None),
master_key=None,
)
assert raised is None
assert forwarded is not None
assert forwarded.get("authorization") == "Bearer sk-ant-oat01-caller-oauth-token"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"credential_header",
sorted(SpecialHeaders.litellm_credential_header_names() - {"authorization", "x-api-key", "x-litellm-api-key"}),
)
async def test_every_non_anthropic_credential_header_is_dropped_by_name(self, monkeypatch, credential_header):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-litellm-api-key", self.VKEY.encode()),
(b"x-api-key", b"sk-ant-api03-caller-own-key"),
(credential_header.encode(), b"some-distinct-caller-secret-value"),
(b"content-type", b"application/json"),
],
)
assert raised is None
assert forwarded is not None
assert forwarded.get("x-api-key") == "sk-ant-api03-caller-own-key"
assert credential_header not in forwarded
assert "x-litellm-api-key" not in forwarded
assert self.VKEY not in self._blob(forwarded)
assert "some-distinct-caller-secret-value" not in self._blob(forwarded)
@pytest.mark.asyncio
async def test_virtual_key_in_operator_configured_header_is_stripped(self, monkeypatch):
with mock.patch.dict( # test-quality-ok: general_settings is the real proxy config surface for litellm_key_header_name; no injection seam exists on this route
"litellm.proxy.proxy_server.general_settings",
{"litellm_key_header_name": "x-company-key"},
):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-company-key", f"Bearer {self.VKEY}".encode()),
(b"x-api-key", b"sk-ant-api03-caller-own-key"),
(b"content-type", b"application/json"),
],
)
assert raised is None
assert forwarded is not None
assert forwarded.get("x-api-key") == "sk-ant-api03-caller-own-key"
assert "x-company-key" not in forwarded
assert self.VKEY not in self._blob(forwarded)
@pytest.mark.asyncio
async def test_proxy_credential_replaces_virtual_key_sent_as_bearer(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[
(b"authorization", f"Bearer {self.VKEY}".encode()),
(b"anthropic-version", b"2023-06-01"),
(b"content-type", b"application/json"),
],
proxy_api_key=self.PROXY_KEY,
)
assert raised is None
assert forwarded is not None
assert forwarded.get("x-api-key") == self.PROXY_KEY
assert "authorization" not in forwarded
assert forwarded.get("anthropic-version") == "2023-06-01"
assert self.VKEY not in self._blob(forwarded)
@pytest.mark.asyncio
async def test_proxy_credential_replaces_virtual_key_sent_as_x_api_key(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[(b"x-api-key", self.VKEY.encode()), (b"content-type", b"application/json")],
proxy_api_key=self.PROXY_KEY,
)
assert raised is None
assert forwarded is not None
assert forwarded.get("x-api-key") == self.PROXY_KEY
assert self.VKEY not in self._blob(forwarded)
@pytest.mark.asyncio
async def test_proxy_credential_wins_over_callers_own_x_api_key(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[
(b"x-litellm-api-key", self.VKEY.encode()),
(b"x-api-key", b"sk-ant-api03-caller-own-key"),
(b"content-type", b"application/json"),
],
proxy_api_key=self.PROXY_KEY,
)
assert raised is None
assert forwarded is not None
assert forwarded.get("x-api-key") == self.PROXY_KEY
assert "sk-ant-api03-caller-own-key" not in self._blob(forwarded)
@pytest.mark.asyncio
async def test_x_pass_and_hop_by_hop_handling_is_unchanged(self, monkeypatch):
raised, forwarded = await self._run(
monkeypatch,
[
(b"authorization", f"Bearer {self.VKEY}".encode()),
(b"x-pass-anthropic-beta", b"interleaved-thinking-2025-05-14"),
(b"x-pass-authorization", b"Bearer smuggled"),
(b"content-length", b"2"),
(b"host", b"proxy.internal"),
(b"accept-encoding", b"br"),
(b"user-agent", b"curl/8.7.1"),
],
proxy_api_key=self.PROXY_KEY,
)
assert raised is None
assert forwarded is not None
assert forwarded.get("anthropic-beta") == "interleaved-thinking-2025-05-14"
assert forwarded.get("user-agent") == "curl/8.7.1"
assert "authorization" not in forwarded
assert "content-length" not in forwarded
assert "host" not in forwarded
assert "accept-encoding" not in forwarded
class TestVertexPassthroughDefaultLocationOnShortRoutes:
PROJECT = "test-project"
SHORT_ROUTE = "publishers/google/models/gemini-2.5-flash:generateContent"