mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Merge bac482df58 into 0c98afa780
This commit is contained in:
commit
129978fdbd
11 changed files with 307 additions and 0 deletions
47
litellm/proxy/auth/master_key_policy.py
Normal file
47
litellm/proxy/auth/master_key_policy.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Final, Literal
|
||||
|
||||
from typing_extensions import assert_never
|
||||
|
||||
INSECURE_MASTER_KEYS: Final = frozenset({"sk-1234"})
|
||||
|
||||
InsecureMasterKeyReason = Literal["example_key", "missing"]
|
||||
|
||||
_ALTERNATIVE_AUTH_SETTINGS: Final = ("enable_jwt_auth", "enable_oauth2_auth", "enable_oauth2_proxy_auth", "custom_auth")
|
||||
|
||||
|
||||
def alternative_auth_enabled(general_settings: Mapping[str, object]) -> bool:
|
||||
return any(general_settings.get(k, False) for k in _ALTERNATIVE_AUTH_SETTINGS)
|
||||
|
||||
|
||||
def insecure_master_key_reason(
|
||||
master_key: str | None, alternative_auth_enabled: bool
|
||||
) -> InsecureMasterKeyReason | None:
|
||||
if master_key in INSECURE_MASTER_KEYS:
|
||||
return "example_key"
|
||||
if (master_key is None or master_key == "") and not alternative_auth_enabled:
|
||||
return "missing"
|
||||
return None
|
||||
|
||||
|
||||
def insecure_master_key_warning(master_key: str | None, alternative_auth_enabled: bool) -> str | None:
|
||||
reason: Final = insecure_master_key_reason(master_key, alternative_auth_enabled)
|
||||
match reason:
|
||||
case "example_key":
|
||||
return (
|
||||
"LITELLM_MASTER_KEY is set to the example key 'sk-1234' from the docs. "
|
||||
"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."
|
||||
)
|
||||
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."
|
||||
)
|
||||
case None:
|
||||
return None
|
||||
assert_never(reason)
|
||||
|
|
@ -42,6 +42,11 @@ from litellm.proxy.auth.auth_checks import (
|
|||
from litellm.proxy.auth.auth_utils import (
|
||||
_BANNED_REQUEST_BODY_PARAMS, # pyright: ignore[reportPrivateUsage] # one canonical list, shared with the request-body check
|
||||
)
|
||||
from litellm.proxy.auth.master_key_policy import (
|
||||
InsecureMasterKeyReason,
|
||||
alternative_auth_enabled,
|
||||
insecure_master_key_reason,
|
||||
)
|
||||
from litellm.proxy.auth.model_checks import get_key_models
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
|
||||
|
|
@ -1670,6 +1675,12 @@ def _show_env_credential_login_warning() -> bool:
|
|||
return is_env_credential_login_enabled(general_settings)
|
||||
|
||||
|
||||
def _insecure_master_key_reason() -> InsecureMasterKeyReason | None:
|
||||
from litellm.proxy.proxy_server import general_settings, master_key
|
||||
|
||||
return insecure_master_key_reason(master_key, alternative_auth_enabled=alternative_auth_enabled(general_settings))
|
||||
|
||||
|
||||
async def _get_health_readiness_details(
|
||||
response: Response | None = None,
|
||||
) -> dict[str, Any]:
|
||||
|
|
@ -1712,6 +1723,7 @@ async def _get_health_readiness_details(
|
|||
is_detailed_debug: Final = verbose_logger.isEnabledFor(logging.DEBUG)
|
||||
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()
|
||||
|
||||
# check DB
|
||||
if prisma_client is not None: # if db passed in, check if it's connected
|
||||
|
|
@ -1740,6 +1752,7 @@ async def _get_health_readiness_details(
|
|||
"is_detailed_debug": is_detailed_debug,
|
||||
"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,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
|
|
@ -1753,6 +1766,7 @@ async def _get_health_readiness_details(
|
|||
"is_detailed_debug": is_detailed_debug,
|
||||
"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,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"Service Unhealthy ({e})")
|
||||
|
|
|
|||
|
|
@ -323,6 +323,7 @@ from litellm.proxy.auth.auth_utils import (
|
|||
from litellm.proxy.auth.fallback_model_access import router_fallback_access_check
|
||||
from litellm.proxy.auth.handle_jwt import JWTHandler
|
||||
from litellm.proxy.auth.litellm_license import AUTO_ROUTER_LICENSE_REMEDY, LicenseCheck
|
||||
from litellm.proxy.auth.master_key_policy import alternative_auth_enabled, insecure_master_key_warning
|
||||
from litellm.proxy.auth.model_checks import (
|
||||
expand_wildcard_deployments_for_model_info,
|
||||
get_all_fallbacks,
|
||||
|
|
@ -1167,6 +1168,12 @@ async def proxy_startup_event(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||
if isinstance(worker_config, dict):
|
||||
await initialize(**worker_config)
|
||||
|
||||
_insecure_master_key_warning: Final = insecure_master_key_warning(
|
||||
master_key, alternative_auth_enabled=alternative_auth_enabled(general_settings)
|
||||
)
|
||||
if _insecure_master_key_warning is not None:
|
||||
verbose_proxy_logger.warning(_insecure_master_key_warning)
|
||||
|
||||
# check if DATABASE_URL in environment - load from there
|
||||
if prisma_client is None:
|
||||
_db_url: Final[str | None] = get_secret("DATABASE_URL", None)
|
||||
|
|
|
|||
67
tests/test_litellm/proxy/auth/test_master_key_policy.py
Normal file
67
tests/test_litellm/proxy/auth/test_master_key_policy.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
from typing import Final
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def test_insecure_master_key_reason_for_example_key():
|
||||
assert insecure_master_key_reason("sk-1234", alternative_auth_enabled=False) == "example_key"
|
||||
|
||||
|
||||
def test_insecure_master_key_reason_for_missing_key():
|
||||
assert insecure_master_key_reason(None, alternative_auth_enabled=False) == "missing"
|
||||
assert insecure_master_key_reason("", alternative_auth_enabled=False) == "missing"
|
||||
|
||||
|
||||
def test_insecure_master_key_reason_none_for_strong_key():
|
||||
assert insecure_master_key_reason("sk-strong-random-key", alternative_auth_enabled=False) is None
|
||||
|
||||
|
||||
def test_insecure_master_key_reason_none_with_alternative_auth():
|
||||
assert insecure_master_key_reason(None, alternative_auth_enabled=True) is None
|
||||
|
||||
|
||||
def test_insecure_master_key_reason_none_with_custom_auth():
|
||||
general_settings: Final = {"custom_auth": "my_package.custom_auth_handler"}
|
||||
|
||||
assert insecure_master_key_reason(None, alternative_auth_enabled=alternative_auth_enabled(general_settings)) is None
|
||||
|
||||
|
||||
def test_insecure_master_key_warning_returned_for_example_key():
|
||||
warning = insecure_master_key_warning("sk-1234", alternative_auth_enabled=False)
|
||||
|
||||
assert warning is not None
|
||||
assert "sk-1234" in warning
|
||||
|
||||
|
||||
def test_insecure_master_key_warning_for_missing_key():
|
||||
warning = insecure_master_key_warning(None, alternative_auth_enabled=False)
|
||||
|
||||
assert warning is not None
|
||||
assert "No master key" in warning
|
||||
|
||||
|
||||
def test_insecure_master_key_warning_for_empty_key():
|
||||
warning = insecure_master_key_warning("", alternative_auth_enabled=False)
|
||||
|
||||
assert warning is not None
|
||||
assert "No master key" in warning
|
||||
|
||||
|
||||
def test_insecure_master_key_warning_none_for_missing_key_with_alt_auth():
|
||||
assert insecure_master_key_warning(None, alternative_auth_enabled=True) is None
|
||||
|
||||
|
||||
def test_insecure_master_key_warning_none_for_strong_key():
|
||||
assert insecure_master_key_warning("sk-strong-random-key", alternative_auth_enabled=False) is None
|
||||
|
||||
|
||||
def test_insecure_master_key_warning_survives_redaction():
|
||||
warning = insecure_master_key_warning("sk-1234", alternative_auth_enabled=False)
|
||||
|
||||
assert warning is not None
|
||||
assert "secrets.token_urlsafe" in redact_string(warning)
|
||||
|
|
@ -1361,6 +1361,34 @@ def test_health_readiness_details_reports_env_credential_login_warning(monkeypat
|
|||
assert response.json()["show_env_credential_login_warning"] is expected_warning
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"master_key, general_settings, expected_reason",
|
||||
[
|
||||
("sk-1234", {}, "example_key"),
|
||||
(None, {}, "missing"),
|
||||
("", {}, "missing"),
|
||||
(None, {"enable_jwt_auth": True}, None),
|
||||
("sk-strong-random-key", {}, None),
|
||||
],
|
||||
)
|
||||
def test_health_readiness_details_reports_insecure_master_key_reason(
|
||||
monkeypatch, master_key, general_settings, expected_reason
|
||||
):
|
||||
app = FastAPI()
|
||||
app.include_router(_health_endpoints_module.router)
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
client = TestClient(app)
|
||||
|
||||
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)
|
||||
|
||||
response = client.get("/health/readiness/details")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["insecure_master_key_reason"] == expected_reason
|
||||
|
||||
|
||||
def test_health_readiness_allows_explicit_legacy_public_details(monkeypatch):
|
||||
"""
|
||||
Operators can explicitly preserve the legacy public readiness payload.
|
||||
|
|
|
|||
|
|
@ -1073,3 +1073,37 @@ async def test_prometheus_fallback_stats_job_runs_when_the_lock_is_free_or_absen
|
|||
await jobs["prometheus_fallback_stats_job"]()
|
||||
|
||||
assert send_fallback_stats.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_startup_event_warns_but_does_not_raise_for_docs_example_master_key(monkeypatch, caplog):
|
||||
monkeypatch.setenv("LITELLM_MASTER_KEY", "sk-1234")
|
||||
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
|
||||
async with proxy_startup_event(app=None):
|
||||
pass
|
||||
except ValueError as e:
|
||||
if "sk-1234" in str(e):
|
||||
pytest.fail("proxy_startup_event refused to boot on the docs example key")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert "sk-1234" in caplog.text, "startup should log the insecure master key warning"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_startup_event_warns_but_does_not_raise_for_missing_master_key(monkeypatch, caplog):
|
||||
monkeypatch.delenv("LITELLM_MASTER_KEY", raising=False)
|
||||
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
|
||||
async with proxy_startup_event(app=None):
|
||||
pass
|
||||
except ValueError as e:
|
||||
if "master key" in str(e).lower():
|
||||
pytest.fail("proxy_startup_event refused to boot without a master key")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert "No master key" in caplog.text, "startup should log the missing master key warning"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface HealthReadinessDetailsResponse {
|
|||
is_detailed_debug?: boolean;
|
||||
show_no_redis_warning?: boolean;
|
||||
show_env_credential_login_warning?: boolean;
|
||||
insecure_master_key_reason?: "example_key" | "missing" | null;
|
||||
}
|
||||
|
||||
const fetchHealthReadinessDetails = async (accessToken: string): Promise<HealthReadinessDetailsResponse> => {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ vi.mock("@/components/EnvCredentialLoginWarningBanner", () => ({
|
|||
EnvCredentialLoginWarningBanner: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/InsecureMasterKeyWarningBanner", () => ({
|
||||
InsecureMasterKeyWarningBanner: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/LicenseExpiryBanner", () => ({
|
||||
LicenseExpiryBanner: () => null,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||
import { DebugWarningBanner } from "@/components/DebugWarningBanner";
|
||||
import { NoRedisWarningBanner } from "@/components/NoRedisWarningBanner";
|
||||
import { EnvCredentialLoginWarningBanner } from "@/components/EnvCredentialLoginWarningBanner";
|
||||
import { InsecureMasterKeyWarningBanner } from "@/components/InsecureMasterKeyWarningBanner";
|
||||
import { LicenseExpiryBanner } from "@/components/LicenseExpiryBanner";
|
||||
import { UserBanner } from "@/components/UserBanner";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
|
|
@ -115,6 +116,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
|||
<DebugWarningBanner accessToken={accessToken} />
|
||||
<NoRedisWarningBanner accessToken={accessToken} />
|
||||
<EnvCredentialLoginWarningBanner accessToken={accessToken} />
|
||||
<InsecureMasterKeyWarningBanner accessToken={accessToken} />
|
||||
<LicenseExpiryBanner accessToken={accessToken} />
|
||||
<UserBanner accessToken={accessToken} />
|
||||
<main className="flex min-h-0 flex-1 overflow-hidden">
|
||||
|
|
@ -135,6 +137,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
|||
<DebugWarningBanner accessToken={accessToken} />
|
||||
<NoRedisWarningBanner accessToken={accessToken} />
|
||||
<EnvCredentialLoginWarningBanner accessToken={accessToken} />
|
||||
<InsecureMasterKeyWarningBanner accessToken={accessToken} />
|
||||
<LicenseExpiryBanner accessToken={accessToken} />
|
||||
<UserBanner accessToken={accessToken} />
|
||||
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { renderWithProviders, screen } from "../../tests/test-utils";
|
||||
import { vi } from "vitest";
|
||||
import { InsecureMasterKeyWarningBanner } from "./InsecureMasterKeyWarningBanner";
|
||||
import type { HealthReadinessDetailsResponse } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({
|
||||
useHealthReadinessDetails: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
|
||||
|
||||
const mockDetails = (data: Partial<HealthReadinessDetailsResponse> | undefined) => {
|
||||
vi.mocked(useHealthReadinessDetails).mockReturnValue({ data } as UseQueryResult<HealthReadinessDetailsResponse>);
|
||||
};
|
||||
|
||||
describe("InsecureMasterKeyWarningBanner", () => {
|
||||
it("should warn when the proxy reports the docs example key", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("should render nothing when the configured key is strong", () => {
|
||||
mockDetails({ status: "healthy", insecure_master_key_reason: null });
|
||||
const { container } = renderWithProviders(<InsecureMasterKeyWarningBanner accessToken="token" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("should render nothing when readiness details are unavailable", () => {
|
||||
mockDetails(undefined);
|
||||
const { container } = renderWithProviders(<InsecureMasterKeyWarningBanner accessToken={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("should pass the access token to the readiness hook", () => {
|
||||
mockDetails(undefined);
|
||||
renderWithProviders(<InsecureMasterKeyWarningBanner accessToken="my-token" />);
|
||||
expect(useHealthReadinessDetails).toHaveBeenCalledWith("my-token");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
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.
|
||||
</>
|
||||
),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const InsecureMasterKeyWarningBanner: React.FC<{ accessToken: string | null }> = ({ accessToken }) => {
|
||||
const { data: healthData } = useHealthReadinessDetails(accessToken);
|
||||
const reason = healthData?.insecure_master_key_reason;
|
||||
|
||||
if (reason == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { title, body } = BANNER_CONTENT[reason];
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-3 border-b border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
<TriangleAlert className="mt-0.5 size-5 shrink-0" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="font-semibold">{title}</p>
|
||||
<p>{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue