fix(proxy): keep a caller's own Anthropic key when the proxy has no master key

Without a master key the auth layer echoes whatever key the caller presented as the authenticated key, so the passthrough's strip-by-value matched the caller's own Anthropic key and dropped it: a bring-your-own-key request that returned 200 on main answered 401 telling the caller to send the key they had just sent. Only the auth module's own no-auth dev-mode definition, shared through is_no_auth_dev_mode, decides that nothing was authenticated, and only when no custom auth is installed; JWTs, OAuth2 tokens, and custom-auth credentials are still stripped there. The sk- prefix heuristic goes with it.

The Vertex credential-less test now sets a master key, since a virtual key can only authenticate under one: the auth layer returns before any key lookup when the master key is unset.
This commit is contained in:
mateo-berri 2026-09-16 12:52:37 -07:00
parent 878fe17735
commit 043c954aa9
3 changed files with 66 additions and 9 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,
)
@ -2038,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()):
@ -2047,11 +2051,11 @@ 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())

View file

@ -586,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"
@ -4401,6 +4402,55 @@ class TestAnthropicPassthroughVirtualKeyLeak:
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(