feat(proxy): lock out credential storage and virtual key management while the master key is insecure

This commit is contained in:
Devin AI 2026-09-15 13:52:30 +00:00
parent b9b0f0e7ea
commit 74c0e1a339
10 changed files with 375 additions and 40 deletions

View file

@ -1,7 +1,11 @@
from collections.abc import Mapping
from typing import Final, Literal
from collections.abc import Mapping, Sequence
from typing import Final, Literal, cast
from typing_extensions import assert_never
from pydantic import TypeAdapter
from typing_extensions import TypedDict, assert_never
from litellm.proxy._types import LiteLLMRoutes, ProxyErrorTypes, ProxyException
from litellm.proxy.auth.route_checks import RouteChecks
INSECURE_MASTER_KEYS: Final = frozenset({"sk-1234"})
@ -9,6 +13,51 @@ InsecureMasterKeyReason = Literal["example_key", "missing"]
_ALTERNATIVE_AUTH_SETTINGS: Final = ("enable_jwt_auth", "enable_oauth2_auth", "enable_oauth2_proxy_auth", "custom_auth")
LockoutAction = Literal["store_credentials", "access_credentials", "use_credentials", "manage_virtual_keys"]
MASTER_KEY_LOCKOUT_MESSAGE: Final = (
"This functionality is unavailable until the master key has been set. "
"Set LITELLM_MASTER_KEY (or general_settings.master_key) to a strong random key and restart the proxy."
)
_STORE_CREDENTIAL_ROUTES: Final = (
"/credentials",
"/credentials/{credential_name:path}",
"/model/new",
"/model/update",
"/model/{model_id}/update",
"/config/update",
)
class _ModelInfoMarker(TypedDict, total=False):
db_model: bool
class _DeploymentMarker(TypedDict, total=False):
model_info: _ModelInfoMarker
_DEPLOYMENT_MARKERS: Final = TypeAdapter(list[_DeploymentMarker])
_ACCESS_CREDENTIAL_ROUTES: Final = (
"/credentials",
"/credentials/by_name/{credential_name:path}",
"/credentials/by_model",
"/model/info",
"/v1/model/info",
"/v2/model/info",
"/get/config/callbacks",
"/config/list",
"/config/field/info",
)
def _route_matches_any(route: str, patterns: Sequence[str]) -> bool:
return any(
route == pattern or RouteChecks.route_matches_pattern(route=route, pattern=pattern) for pattern in patterns
)
def alternative_auth_enabled(general_settings: Mapping[str, object]) -> bool:
return any(general_settings.get(k, False) for k in _ALTERNATIVE_AUTH_SETTINGS)
@ -33,15 +82,66 @@ def insecure_master_key_warning(master_key: str | None, alternative_auth_enabled
"Anyone who has read the docs can administer this gateway, and publicly reachable "
"gateways using this key have been compromised. Set a strong random master key "
"(e.g. `python -c \"import secrets; print('sk-' + secrets.token_urlsafe(32))\"`). "
"A future release will refuse to start with this key."
"Storing and using upstream credentials and managing virtual keys are disabled "
"until a strong master key is set."
)
case "missing":
return (
"No master key is set (LITELLM_MASTER_KEY or general_settings.master_key). "
"Every request to this proxy is accepted without authentication, including "
'admin routes. Set a strong random master key (e.g. `python -c "import secrets; '
"print('sk-' + secrets.token_urlsafe(32))\"`) before exposing it to a network."
"print('sk-' + secrets.token_urlsafe(32))\"`) before exposing it to a network. "
"Storing and using upstream credentials and managing virtual keys are disabled "
"until a strong master key is set."
)
case None:
return None
assert_never(reason)
def stored_credentials_present() -> bool:
import litellm
if litellm.credential_list:
return True
from litellm import Router
from litellm.proxy.proxy_server import llm_router
if not isinstance(llm_router, Router):
return False
model_list: Final = cast(
"list[object]",
llm_router.model_list or [], # pyright: ignore[reportUnknownMemberType] # Router.model_list is declared bare `list`; elements are validated by the TypeAdapter below
)
deployments: Final = _DEPLOYMENT_MARKERS.validate_python(model_list)
return any((d.get("model_info") or {}).get("db_model") is True for d in deployments)
def master_key_lockout_action(
route: str,
method: str,
reason: InsecureMasterKeyReason | None,
stored_credentials_present: bool,
) -> LockoutAction | None:
if reason is None:
return None
if method.upper() != "GET" and _route_matches_any(route, _STORE_CREDENTIAL_ROUTES):
return "store_credentials"
if not stored_credentials_present:
return None
if method.upper() == "GET" and _route_matches_any(route, _ACCESS_CREDENTIAL_ROUTES):
return "access_credentials"
if method.upper() != "GET" and RouteChecks.is_llm_api_route(route=route):
return "use_credentials"
if method.upper() != "GET" and _route_matches_any(route, tuple(LiteLLMRoutes.key_management_routes.value)):
return "manage_virtual_keys"
return None
def master_key_lockout_exception(action: LockoutAction) -> ProxyException:
return ProxyException(
message=MASTER_KEY_LOCKOUT_MESSAGE,
type=ProxyErrorTypes.auth_error,
param="master_key",
code=403,
)

View file

@ -486,6 +486,10 @@ class RouteChecks:
return True
return False
@staticmethod
def route_matches_pattern(route: str, pattern: str) -> bool:
return RouteChecks._route_matches_pattern(route=route, pattern=pattern)
@staticmethod
def _route_matches_pattern(route: str, pattern: str) -> bool:
"""

View file

@ -79,6 +79,13 @@ from litellm.proxy.auth.auth_utils import (
route_in_additonal_public_routes,
)
from litellm.proxy.auth.handle_jwt import JWTAuthManager, JWTHandler
from litellm.proxy.auth.master_key_policy import (
alternative_auth_enabled,
insecure_master_key_reason,
master_key_lockout_action,
master_key_lockout_exception,
stored_credentials_present,
)
from litellm.proxy.auth.network import TrustedProxyConfig, resolve_network_context
from litellm.proxy.auth.oauth2_check import Oauth2Handler
from litellm.proxy.auth.oauth2_proxy_hook import handle_oauth2_proxy_request
@ -1344,6 +1351,18 @@ async def _user_api_key_auth_builder(
custom_auth_api_key: bool = False
try:
_lockout_reason: Final = insecure_master_key_reason(
master_key, alternative_auth_enabled=alternative_auth_enabled(general_settings)
)
if _lockout_reason is not None:
_lockout_action: Final = master_key_lockout_action(
route=route,
method=request.method,
reason=_lockout_reason,
stored_credentials_present=stored_credentials_present(),
)
if _lockout_action is not None:
raise master_key_lockout_exception(_lockout_action)
with tracer.trace("litellm.proxy.auth.pre_db_read_auth_checks"):
await pre_db_read_auth_checks(
request_data=request_data,

View file

@ -46,6 +46,7 @@ from litellm.proxy.auth.master_key_policy import (
InsecureMasterKeyReason,
alternative_auth_enabled,
insecure_master_key_reason,
stored_credentials_present,
)
from litellm.proxy.auth.model_checks import get_key_models
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
@ -1732,6 +1733,7 @@ async def _get_health_readiness_details(
show_no_redis_warning: Final = await _show_no_redis_warning()
show_env_credential_login_warning: Final = _show_env_credential_login_warning()
insecure_master_key_reason: Final = _insecure_master_key_reason()
stored_credentials_locked: Final = insecure_master_key_reason is not None and stored_credentials_present()
# check DB
if prisma_client is not None: # if db passed in, check if it's connected
@ -1761,6 +1763,7 @@ async def _get_health_readiness_details(
"show_no_redis_warning": show_no_redis_warning,
"show_env_credential_login_warning": show_env_credential_login_warning,
"insecure_master_key_reason": insecure_master_key_reason,
"stored_credentials_locked": stored_credentials_locked,
}
else:
return {
@ -1775,6 +1778,7 @@ async def _get_health_readiness_details(
"show_no_redis_warning": show_no_redis_warning,
"show_env_credential_login_warning": show_env_credential_login_warning,
"insecure_master_key_reason": insecure_master_key_reason,
"stored_credentials_locked": stored_credentials_locked,
}
except Exception as e:
raise HTTPException(status_code=503, detail=f"Service Unhealthy ({e})")

View file

@ -1,10 +1,13 @@
from typing import Final
import pytest
from litellm.litellm_core_utils.secret_redaction import redact_string
from litellm.proxy.auth.master_key_policy import (
alternative_auth_enabled,
insecure_master_key_reason,
insecure_master_key_warning,
master_key_lockout_action,
)
@ -65,3 +68,80 @@ def test_insecure_master_key_warning_survives_redaction():
assert warning is not None
assert "secrets.token_urlsafe" in redact_string(warning)
@pytest.mark.parametrize(
"route,method,expected",
[
("/credentials", "POST", "store_credentials"),
("/credentials/my_creds", "PATCH", "store_credentials"),
("/credentials/my_creds", "DELETE", "store_credentials"),
("/model/new", "POST", "store_credentials"),
("/model/update", "POST", "store_credentials"),
("/model/abc-123/update", "PATCH", "store_credentials"),
("/config/update", "POST", "store_credentials"),
("/model/delete", "POST", None),
("/model/block", "POST", None),
("/model/unblock", "POST", None),
("/credentials", "GET", "access_credentials"),
("/credentials/by_name/my_creds", "GET", "access_credentials"),
("/credentials/by_model", "GET", "access_credentials"),
("/model/info", "GET", "access_credentials"),
("/v1/model/info", "GET", "access_credentials"),
("/v2/model/info", "GET", "access_credentials"),
("/get/config/callbacks", "GET", "access_credentials"),
("/config/list", "GET", "access_credentials"),
("/config/field/info", "GET", "access_credentials"),
("/chat/completions", "POST", "use_credentials"),
("/v1/chat/completions", "POST", "use_credentials"),
("/v1/embeddings", "POST", "use_credentials"),
("/v1/messages", "POST", "use_credentials"),
("/key/generate", "POST", "manage_virtual_keys"),
("/key/update", "POST", "manage_virtual_keys"),
("/key/delete", "POST", "manage_virtual_keys"),
("/key/abc-def/regenerate", "POST", "manage_virtual_keys"),
("/key/service-account/generate", "POST", "manage_virtual_keys"),
("/key/block", "POST", "manage_virtual_keys"),
("/key/info", "GET", None),
("/key/list", "GET", None),
("/login", "POST", None),
("/health/readiness", "GET", None),
("/health/readiness/details", "GET", None),
("/models", "GET", None),
("/v1/models", "GET", None),
],
)
@pytest.mark.parametrize("reason", ["example_key", "missing"])
def test_master_key_lockout_action(route, method, expected, reason):
stored: Final = expected != "store_credentials"
assert master_key_lockout_action(route, method, reason, stored_credentials_present=stored) == expected
@pytest.mark.parametrize(
"route,method",
[
("/credentials", "POST"),
("/model/new", "POST"),
("/credentials", "GET"),
("/model/info", "GET"),
("/chat/completions", "POST"),
("/key/generate", "POST"),
],
)
def test_master_key_lockout_action_none_when_key_secure(route, method):
assert master_key_lockout_action(route, method, None, stored_credentials_present=True) is None
@pytest.mark.parametrize(
"route,method",
[
("/credentials", "GET"),
("/model/info", "GET"),
("/chat/completions", "POST"),
("/key/generate", "POST"),
("/key/abc/regenerate", "POST"),
],
)
@pytest.mark.parametrize("reason", ["example_key", "missing"])
def test_master_key_lockout_action_not_blocked_without_stored_credentials(route, method, reason):
assert master_key_lockout_action(route, method, reason, stored_credentials_present=False) is None

View file

@ -7628,3 +7628,109 @@ async def test_claude_view_never_reinterprets_explicit_names(monkeypatch, layer)
assert data["model"] == ("foo" if layer == "unclaimed" else encoded)
await _normalize_claude_model(data, token, request, "/v1/messages")
assert data["model"] == ("foo" if layer == "unclaimed" else encoded)
@pytest.fixture
def stored_credential():
import litellm
from litellm.models.credentials import CredentialItem
litellm.credential_list = [
CredentialItem(credential_name="test-cred", credential_info={}, credential_values={"api_key": "sk-upstream"})
]
yield
litellm.credential_list = []
def _proxy_server_attrs_for_lockout(master_key: str | None):
mock_cache = AsyncMock()
mock_cache.async_get_cache = AsyncMock(return_value=None)
mock_cache.delete_cache = MagicMock()
mock_proxy_logging_obj = MagicMock()
mock_proxy_logging_obj.internal_usage_cache = MagicMock()
mock_proxy_logging_obj.internal_usage_cache.dual_cache = AsyncMock()
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
return {
"prisma_client": MagicMock(),
"user_api_key_cache": mock_cache,
"proxy_logging_obj": mock_proxy_logging_obj,
"master_key": master_key,
"general_settings": {},
"llm_model_list": [],
"llm_router": None,
"open_telemetry_logger": None,
"model_max_budget_limiter": MagicMock(),
"user_custom_auth": None,
"jwt_handler": None,
"litellm_proxy_admin_name": "admin",
}
@pytest.mark.asyncio
async def test_insecure_master_key_locks_out_key_management_with_stored_credentials(stored_credential):
from fastapi import Request
from starlette.datastructures import URL
import litellm.proxy.proxy_server as _proxy_server_mod
from litellm.proxy._types import ProxyException
from litellm.proxy.auth.user_api_key_auth import _user_api_key_auth_builder
attrs = _proxy_server_attrs_for_lockout(master_key="sk-1234")
originals = {attr: getattr(_proxy_server_mod, attr, None) for attr in attrs}
try:
for attr, val in attrs.items():
setattr(_proxy_server_mod, attr, val)
request = Request(scope={"type": "http", "method": "POST"})
request._url = URL(url="/key/generate")
with pytest.raises(ProxyException) as exc_info:
await _user_api_key_auth_builder(
request=request,
api_key="Bearer sk-1234",
azure_api_key_header="",
anthropic_api_key_header=None,
google_ai_studio_api_key_header=None,
azure_apim_header=None,
request_data={},
)
assert exc_info.value.code == "403"
assert "unavailable until the master key has been set" in exc_info.value.message
finally:
for attr, val in originals.items():
setattr(_proxy_server_mod, attr, val)
@pytest.mark.asyncio
async def test_strong_master_key_passes_auth_on_key_management(stored_credential):
from fastapi import Request
from starlette.datastructures import URL
import litellm.proxy.proxy_server as _proxy_server_mod
from litellm.proxy._types import LitellmUserRoles
from litellm.proxy.auth.user_api_key_auth import _user_api_key_auth_builder
attrs = _proxy_server_attrs_for_lockout(master_key="sk-strong-random-key")
originals = {attr: getattr(_proxy_server_mod, attr, None) for attr in attrs}
try:
for attr, val in attrs.items():
setattr(_proxy_server_mod, attr, val)
request = Request(scope={"type": "http", "method": "POST"})
request._url = URL(url="/key/generate")
result = await _user_api_key_auth_builder(
request=request,
api_key="Bearer sk-strong-random-key",
azure_api_key_header="",
anthropic_api_key_header=None,
google_ai_studio_api_key_header=None,
azure_apim_header=None,
request_data={},
)
assert result.user_role == LitellmUserRoles.PROXY_ADMIN
finally:
for attr, val in originals.items():
setattr(_proxy_server_mod, attr, val)

View file

@ -1417,17 +1417,20 @@ def test_health_readiness_details_reports_env_credential_login_warning(monkeypat
@pytest.mark.parametrize(
"master_key, general_settings, expected_reason",
"master_key, general_settings, has_stored_credentials, expected_reason, expected_locked",
[
("sk-1234", {}, "example_key"),
(None, {}, "missing"),
("", {}, "missing"),
(None, {"enable_jwt_auth": True}, None),
("sk-strong-random-key", {}, None),
("sk-1234", {}, True, "example_key", True),
("sk-1234", {}, False, "example_key", False),
(None, {}, True, "missing", True),
(None, {}, False, "missing", False),
("", {}, False, "missing", False),
(None, {"enable_jwt_auth": True}, True, None, False),
("sk-strong-random-key", {}, True, None, False),
("sk-strong-random-key", {}, False, None, False),
],
)
def test_health_readiness_details_reports_insecure_master_key_reason(
monkeypatch, master_key, general_settings, expected_reason
monkeypatch, master_key, general_settings, has_stored_credentials, expected_reason, expected_locked
):
app = FastAPI()
app.include_router(_health_endpoints_module.router)
@ -1437,11 +1440,20 @@ def test_health_readiness_details_reports_insecure_master_key_reason(
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", master_key)
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None)
monkeypatch.setattr(
litellm,
"credential_list",
[CredentialItem(credential_name="test-cred", credential_info={}, credential_values={"api_key": "sk-x"})]
if has_stored_credentials
else [],
)
response = client.get("/health/readiness/details")
assert response.status_code == 200, response.text
assert response.json()["insecure_master_key_reason"] == expected_reason
assert response.json()["stored_credentials_locked"] is expected_locked
def test_health_readiness_allows_explicit_legacy_public_details(monkeypatch):

View file

@ -16,6 +16,7 @@ export interface HealthReadinessDetailsResponse {
show_no_redis_warning?: boolean;
show_env_credential_login_warning?: boolean;
insecure_master_key_reason?: "example_key" | "missing" | null;
stored_credentials_locked?: boolean;
}
const fetchHealthReadinessDetails = async (accessToken: string): Promise<HealthReadinessDetailsResponse> => {

View file

@ -19,15 +19,29 @@ describe("InsecureMasterKeyWarningBanner", () => {
mockDetails({ status: "healthy", insecure_master_key_reason: "example_key" });
renderWithProviders(<InsecureMasterKeyWarningBanner accessToken="token" />);
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText("The master key is the docs example key sk-1234")).toBeInTheDocument();
expect(screen.getByText("The master key has not been set")).toBeInTheDocument();
expect(screen.getByText(/docs example value sk-1234/)).toBeInTheDocument();
expect(screen.getByText(/store or use upstream credentials or manage virtual keys/)).toBeInTheDocument();
});
it("should warn when the proxy reports no master key", () => {
mockDetails({ status: "healthy", insecure_master_key_reason: "missing" });
renderWithProviders(<InsecureMasterKeyWarningBanner accessToken="token" />);
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText("No master key is set")).toBeInTheDocument();
expect(screen.getByText(/accepted without authentication/)).toBeInTheDocument();
expect(screen.getByText("The master key has not been set")).toBeInTheDocument();
expect(screen.getByText(/store or use upstream credentials or manage virtual keys/)).toBeInTheDocument();
});
it("should note stored credentials are locked when the proxy reports them", () => {
mockDetails({ status: "healthy", insecure_master_key_reason: "example_key", stored_credentials_locked: true });
renderWithProviders(<InsecureMasterKeyWarningBanner accessToken="token" />);
expect(screen.getByText(/Credentials already stored on this proxy/)).toBeInTheDocument();
});
it("should not mention stored credentials when none are locked", () => {
mockDetails({ status: "healthy", insecure_master_key_reason: "missing", stored_credentials_locked: false });
renderWithProviders(<InsecureMasterKeyWarningBanner accessToken="token" />);
expect(screen.queryByText(/Credentials already stored on this proxy/)).not.toBeInTheDocument();
});
it("should render nothing when the configured key is strong", () => {

View file

@ -4,27 +4,21 @@ import React from "react";
import { TriangleAlert } from "lucide-react";
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
const BANNER_CONTENT = {
example_key: {
title: "The master key is the docs example key sk-1234",
body: (
<>
Anyone who has read the LiteLLM docs can administer this gateway. Generate a strong random key, set it as{" "}
<code className="font-mono">LITELLM_MASTER_KEY</code> (or{" "}
<code className="font-mono">general_settings.master_key</code>), and restart the proxy.
</>
),
},
missing: {
title: "No master key is set",
body: (
<>
Every request to this proxy is accepted without authentication, including admin routes. Set{" "}
<code className="font-mono">LITELLM_MASTER_KEY</code> (or{" "}
<code className="font-mono">general_settings.master_key</code>) to a strong random key and restart the proxy.
</>
),
},
const REMEDIATION = (
<>
You must set <code className="font-mono">LITELLM_MASTER_KEY</code> (or{" "}
<code className="font-mono">general_settings.master_key</code>) to a strong random key and restart the proxy before
you can store or use upstream credentials or manage virtual keys.
</>
);
const BANNER_BODIES = {
example_key: (
<>
The master key is set to the docs example value sk-1234, which does not count as set. {REMEDIATION}
</>
),
missing: REMEDIATION,
} as const;
export const InsecureMasterKeyWarningBanner: React.FC<{ accessToken: string | null }> = ({ accessToken }) => {
@ -35,8 +29,6 @@ export const InsecureMasterKeyWarningBanner: React.FC<{ accessToken: string | nu
return null;
}
const { title, body } = BANNER_CONTENT[reason];
return (
<div
role="alert"
@ -44,8 +36,11 @@ export const InsecureMasterKeyWarningBanner: React.FC<{ accessToken: string | nu
>
<TriangleAlert className="mt-0.5 size-5 shrink-0" aria-hidden="true" />
<div>
<p className="font-semibold">{title}</p>
<p>{body}</p>
<p className="font-semibold">The master key has not been set</p>
<p>{BANNER_BODIES[reason]}</p>
{healthData?.stored_credentials_locked === true && (
<p>Credentials already stored on this proxy cannot be viewed or used until then.</p>
)}
</div>
</div>
);