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 <noreply@anthropic.com>
This commit is contained in:
Joris Stander 2026-10-17 20:57:54 +02:00
parent 571ef3e2ae
commit c139dc5777
3 changed files with 56 additions and 6 deletions

View file

@ -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,

View file

@ -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)

View file

@ -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