From c139dc57776f629bdf54fe82fffcb09d7b0c4c7b Mon Sep 17 00:00:00 2001 From: Joris Stander <25158146+jorisstander@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:57:54 +0200 Subject: [PATCH] fix(proxy/auth): reject non-master keys with 401 when no database is configured With no DATABASE_URL, a key that isn't the master key now raises a 401 auth_error instead of a 400 no_db_connection. allow_requests_on_db_unavailable keys off no_db_connection, so a proxy without a database no longer fails open and accepts any key. A configured but unreachable database keeps no_db_connection, so the fallback still works during a real outage. Fixes #47119 Fixes #12273 Co-Authored-By: Claude Opus 5.5 --- litellm/proxy/auth/user_api_key_auth.py | 14 ++++++- .../unit/proxy/auth/test_user_api_key_auth.py | 41 ++++++++++++++++++- .../test_user_api_key_auth_request_flow.py | 7 ++-- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index a1abed202a2..37922d1ca24 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -184,7 +184,7 @@ from litellm.router_utils.fallback_event_handlers import ( fallbacks_disabled_for_request, get_fallback_model_group_for_lookup_groups, ) -from litellm.secret_managers.main import get_secret_bool +from litellm.secret_managers.main import get_secret_bool, get_secret_str from litellm.types.services import ServiceTypes try: @@ -2737,6 +2737,18 @@ async def user_api_key_auth_builder( if ( prisma_client is None ): # if both master key + user key submitted, and user key != master key, and no db connected, raise an error + if get_secret_str("DATABASE_URL") is None: + # No DB configured: an auth error, so allow_requests_on_db_unavailable can't fail open + raise ProxyException( + message=( + "Authentication Error, Invalid proxy server token passed. Token does not match " + "the configured master key, and no database is configured to look up virtual keys. " + "Set DATABASE_URL to enable virtual keys." + ), + type=ProxyErrorTypes.auth_error, + code=status.HTTP_401_UNAUTHORIZED, + param=None, + ) raise ProxyException( message="No connected db.", type=ProxyErrorTypes.no_db_connection, diff --git a/tests/unit/proxy/auth/test_user_api_key_auth.py b/tests/unit/proxy/auth/test_user_api_key_auth.py index 3660019cc2c..461bc257415 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth.py @@ -476,9 +476,9 @@ def test_is_api_route_allowed(route, user_role, expected_result): @pytest.mark.asyncio -async def test_auth_not_connected_to_db(): +async def test_auth_not_connected_to_db(monkeypatch: pytest.MonkeyPatch): """ - ensure requests don't fail when `prisma_client` = None + ensure requests don't fail when `prisma_client` = None but a database is configured """ from fastapi import Request from starlette.datastructures import URL @@ -486,6 +486,7 @@ async def test_auth_not_connected_to_db(): from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.proxy_server import hash_token, user_api_key_cache + monkeypatch.setenv("DATABASE_URL", "postgresql://unreachable.invalid:5432/litellm") user_key = "sk-98765678" setattr(litellm.proxy.proxy_server, "user_api_key_cache", user_api_key_cache) @@ -506,6 +507,42 @@ async def test_auth_not_connected_to_db(): assert valid_token.token == "failed-to-connect-to-db" +@pytest.mark.asyncio +@pytest.mark.parametrize("allow_requests_on_db_unavailable", [True, False]) +async def test_auth_no_db_configured_rejects_non_master_key( + allow_requests_on_db_unavailable: bool, monkeypatch: pytest.MonkeyPatch +): + """ + With no database configured, a key that isn't the master key is an auth error (401), and + allow_requests_on_db_unavailable must not fail open: there is no outage to ride out. + """ + from fastapi import Request + from starlette.datastructures import URL + + from litellm.proxy._types import ProxyErrorTypes, ProxyException + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + from litellm.proxy.proxy_server import user_api_key_cache + + monkeypatch.delenv("DATABASE_URL", raising=False) + setattr(litellm.proxy.proxy_server, "user_api_key_cache", user_api_key_cache) + setattr(litellm.proxy.proxy_server, "master_key", MASTER_KEY) + setattr(litellm.proxy.proxy_server, "prisma_client", None) + setattr( + litellm.proxy.proxy_server, + "general_settings", + {"allow_requests_on_db_unavailable": allow_requests_on_db_unavailable}, + ) + + request = Request(scope={"type": "http", "method": "POST", "path": "/chat/completions", "headers": []}) + request._url = URL(url="/chat/completions") + + with pytest.raises(ProxyException) as exc_info: + await user_api_key_auth(request=request, api_key="Bearer sk-not-the-master-key") + + assert exc_info.value.type == ProxyErrorTypes.auth_error + assert exc_info.value.code == "401" + + def _assert_api_key_from_custom_header(headers, custom_header_name, expected_api_key): verbose_proxy_logger.setLevel(logging.DEBUG) request = MagicMock(spec=Request) diff --git a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py index 4cdd7fefd28..ef5631c5353 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py @@ -12502,10 +12502,11 @@ async def test_lens_oauth2_validates_identity_without_inference_user_field( [("/lens", False, True), ("/lens/traces", True, False), ("/lens-other", True, True)], ) async def test_lens_oauth2_dispatch_preserves_opt_in_premium_and_route_boundary( - route: str, enabled: bool, premium: bool + route: str, enabled: bool, premium: bool, monkeypatch: pytest.MonkeyPatch ) -> None: from starlette.requests import Request + monkeypatch.delenv("DATABASE_URL", raising=False) request: Final = Request({"type": "http", "method": "GET", "path": route, "headers": [], "query_string": b""}) with ( patch("litellm.proxy.proxy_server.general_settings", {"enable_oauth2_auth": enabled}), @@ -12522,8 +12523,8 @@ async def test_lens_oauth2_dispatch_preserves_opt_in_premium_and_route_boundary( assert error.value.code == "403" assert "premium" in error.value.message.lower() else: - assert error.value.code == "400" - assert "No connected db" in error.value.message + assert error.value.code == "401" + assert "no database is configured" in error.value.message @pytest.mark.asyncio