mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-20 00:11:50 +00:00
feat(proxy): lock out credential storage and virtual key management while the master key is insecure
This commit is contained in:
parent
b9b0f0e7ea
commit
74c0e1a339
10 changed files with 375 additions and 40 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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})")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue