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 1/2] 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 From d95d79093c9ed7ff4f66d612c638d52f5a726a80 Mon Sep 17 00:00:00 2001 From: Joris Stander <25158146+jorisstander@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:37:01 +0200 Subject: [PATCH 2/2] fix(proxy/auth): treat an empty DATABASE_URL as no database configured An empty or whitespace-only DATABASE_URL (e.g. from ${DATABASE_URL:-}) counted as a configured database: startup created a Prisma client that can never connect, and the auth guard fell through to no_db_connection. With allow_requests_on_db_unavailable that still gave any key the fallback identity. Startup now skips the client for an empty URL, and the guard normalizes the value, so these keys get the 401 as well. Co-Authored-By: Claude Opus 5.5 --- litellm/proxy/auth/user_api_key_auth.py | 4 ++-- litellm/proxy/proxy_server.py | 2 +- .../unit/proxy/auth/test_user_api_key_auth.py | 12 ++++++++---- ...test_proxy_server_endpoints_and_startup.py | 19 +++++++++++++++++++ 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 37922d1ca24..0087ac97610 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, get_secret_str +from litellm.secret_managers.main import get_secret_bool, get_secret_str, normalize_nonempty_secret_str from litellm.types.services import ServiceTypes try: @@ -2737,7 +2737,7 @@ 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: + if normalize_nonempty_secret_str(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=( diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 27bfd03d7f8..ccf81705ec5 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -11922,7 +11922,7 @@ class ProxyStartupEvent: """ connected_client: PrismaClient | None = None try: - if database_url is None: + if database_url is None or not database_url.strip(): return None prisma_client = PrismaClient(database_url=database_url, proxy_logging_obj=proxy_logging_obj) 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 461bc257415..a2823f71d76 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth.py @@ -509,12 +509,13 @@ async def test_auth_not_connected_to_db(monkeypatch: pytest.MonkeyPatch): @pytest.mark.asyncio @pytest.mark.parametrize("allow_requests_on_db_unavailable", [True, False]) +@pytest.mark.parametrize("database_url", [None, "", " "]) async def test_auth_no_db_configured_rejects_non_master_key( - allow_requests_on_db_unavailable: bool, monkeypatch: pytest.MonkeyPatch + allow_requests_on_db_unavailable: bool, database_url: str | None, 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. + With no database configured (DATABASE_URL unset or empty), 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 @@ -523,7 +524,10 @@ async def test_auth_no_db_configured_rejects_non_master_key( 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) + if database_url is None: + monkeypatch.delenv("DATABASE_URL", raising=False) + else: + monkeypatch.setenv("DATABASE_URL", database_url) 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) diff --git a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py index fbdd8d4d7fb..ebc39ee220a 100644 --- a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py +++ b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py @@ -14105,6 +14105,25 @@ async def test_setup_prisma_client_retains_connected_client_when_startup_health_ assert result is mock_client +@pytest.mark.asyncio +@pytest.mark.parametrize("database_url", ["", " "]) +async def test_setup_prisma_client_treats_an_empty_database_url_as_unset(database_url): + """An empty DATABASE_URL (e.g. from ``${DATABASE_URL:-}``) can never connect, so no client is + created. Otherwise auth sees a configured-but-unreachable DB and allow_requests_on_db_unavailable + fails open for any key.""" + from litellm.proxy.proxy_server import ProxyStartupEvent + + mock_client = _mock_startup_prisma_client() + with patch.object(proxy_server_module, "PrismaClient", return_value=mock_client): + result = await ProxyStartupEvent._setup_prisma_client( + database_url=database_url, + proxy_logging_obj=MagicMock(), + user_api_key_cache=DualCache(), + ) + + assert result is None + + @pytest.mark.asyncio async def test_setup_prisma_client_arms_health_watchdog_before_startup_health_check( monkeypatch,