From fe480533e862bd25569b36d61411a2774fe0fce6 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 19 Sep 2026 13:44:00 -0700 Subject: [PATCH] feat(proxy)!: refuse to start with an unset, empty, or publicly known master key The proxy used to boot with no master key (every request accepted without authentication) and with sk-1234, the key every example used. It now stops at startup, before it connects to the database, and prints how to fix it: where the bad key came from, a copy-pastable command that generates a secure key, and, when the public key is also encrypting a database, a link to the rotation guide general_settings.dangerously_allow_unsafe_proxy: true or LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true starts the proxy anyway, for local development. CI and test boots that rely on sk-1234 or on no key set it BREAKING CHANGE: deployments with no master key, an empty one, or sk-1234 no longer start until they set a real key or opt in to the override --- .circleci/config.yml | 16 ++ litellm/proxy/_lazy_openapi_snapshot.json | 2 +- litellm/proxy/_types.py | 4 + litellm/proxy/auth/master_key_boot_check.py | 221 ++++++++++++++++++ litellm/proxy/proxy_server.py | 21 ++ render.yaml | 2 + tests/e2e/ui/run_e2e.sh | 1 + tests/proxy_behavior/management/conftest.py | 2 +- .../test_master_key_not_in_db.py | 6 +- tests/proxy_unit_tests/test_aproxy_startup.py | 6 +- .../proxy/auth/test_master_key_boot_check.py | 221 ++++++++++++++++++ tests/test_litellm/proxy/test_proxy_server.py | 77 ++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 5 + 13 files changed, 578 insertions(+), 6 deletions(-) create mode 100644 litellm/proxy/auth/master_key_boot_check.py create mode 100644 tests/test_litellm/proxy/auth/test_master_key_boot_check.py diff --git a/.circleci/config.yml b/.circleci/config.yml index 602604714bd..733af22631e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1650,6 +1650,7 @@ jobs: command: | docker run -d \ -p 4001:4000 \ + -e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \ -e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/litellm_test" \ -e LITELLM_MASTER_KEY="sk-1234" \ --name schema-seed \ @@ -1670,6 +1671,7 @@ jobs: command: | docker run -d \ -p 4000:4000 \ + -e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \ -e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/litellm_test" \ -e DEFAULT_NUM_WORKERS_LITELLM_PROXY=1 \ -e DISABLE_SCHEMA_UPDATE="True" \ @@ -1744,6 +1746,7 @@ jobs: command: | docker run -d \ -p 4000:4000 \ + -e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e USE_PRISMA_MIGRATE=True \ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \ @@ -1839,6 +1842,7 @@ jobs: command: | docker run -d \ -p 4000:4000 \ + -e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e AZURE_API_KEY=$AZURE_API_KEY \ -e AZURE_API_BASE=$AZURE_API_BASE \ @@ -1927,6 +1931,7 @@ jobs: command: | docker run -d \ -p 4000:4000 \ + -e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e REDIS_HOST=$REDIS_HOST \ -e REDIS_PASSWORD=$REDIS_PASSWORD \ @@ -1987,6 +1992,7 @@ jobs: command: | docker run -d \ -p 4000:4000 \ + -e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e REDIS_HOST=$REDIS_HOST \ -e REDIS_PASSWORD=$REDIS_PASSWORD \ @@ -2064,6 +2070,7 @@ jobs: command: | docker run -d \ -p 4000:4000 \ + -e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e REDIS_HOST=host.docker.internal \ -e REDIS_PORT=6379 \ @@ -2146,6 +2153,7 @@ jobs: command: | docker run -d \ -p 4000:4000 \ + -e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e REDIS_HOST=$REDIS_HOST \ -e REDIS_PASSWORD=$REDIS_PASSWORD \ @@ -2168,6 +2176,7 @@ jobs: command: | docker run -d \ -p 4001:4001 \ + -e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e REDIS_HOST=$REDIS_HOST \ -e REDIS_PASSWORD=$REDIS_PASSWORD \ @@ -2245,6 +2254,7 @@ jobs: docker run -d \ --restart on-failure \ -p 4000:4000 \ + -e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e STORE_MODEL_IN_DB="True" \ -e LITELLM_MASTER_KEY="sk-1234" \ @@ -2319,6 +2329,7 @@ jobs: command: | docker run -d \ -p 4000:4000 \ + -e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e REDIS_HOST=$REDIS_HOST \ -e REDIS_PASSWORD=$REDIS_PASSWORD \ @@ -2401,6 +2412,7 @@ jobs: command: | docker run -d \ -p 4000:4000 \ + -e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e LITELLM_MASTER_KEY="sk-1234" \ -e OPENAI_API_KEY=$OPENAI_API_KEY \ @@ -2492,6 +2504,7 @@ jobs: command: | docker run -d \ -p 4000:4000 \ + -e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e LITELLM_MASTER_KEY="sk-1234" \ -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \ @@ -2673,6 +2686,7 @@ jobs: name: Start LiteLLM proxy environment: LITELLM_MASTER_KEY: "sk-1234" + LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY: "true" MOCK_LLM_URL: "http://127.0.0.1:8090/v1" DISABLE_SCHEMA_UPDATE: "true" SERVER_ROOT_PATH: "" @@ -2816,6 +2830,7 @@ jobs: name: Start LiteLLM proxy under a server root path environment: LITELLM_MASTER_KEY: "sk-1234" + LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY: "true" MOCK_LLM_URL: "http://127.0.0.1:8090/v1" DISABLE_SCHEMA_UPDATE: "true" # Output flows to this step's own log, so a boot crash is visible here @@ -2901,6 +2916,7 @@ jobs: command: | docker run --name my-app \ -p 4000:4000 \ + -e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \ -e DEFAULT_NUM_WORKERS_LITELLM_PROXY=1 \ -e DATABASE_URL="postgresql://wrong:wrong@wrong:5432/wrong" \ myapp:latest \ diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 06e157498aa..391f0042ed0 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -19632,7 +19632,7 @@ } } }, - "description": "\n Unified rate-limit error.\n\n Every rate-limit condition surfaced by litellm \u2014 whether it originated from\n an upstream LLM provider, a vendor batch endpoint, or one of litellm's own\n proxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\n max-iterations, etc.) \u2014 is raised as an instance of this class.\n\n The :attr:`category` attribute lets callers distinguish the source. See\n :class:`RateLimitErrorCategory` for the available values.\n " + "description": "\nUnified rate-limit error.\n\nEvery rate-limit condition surfaced by litellm \u2014 whether it originated from\nan upstream LLM provider, a vendor batch endpoint, or one of litellm's own\nproxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\nmax-iterations, etc.) \u2014 is raised as an instance of this class.\n\nThe :attr:`category` attribute lets callers distinguish the source. See\n:class:`RateLimitErrorCategory` for the available values.\n" }, "500": { "content": { diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 6322a1212fe..40a5caf1ba8 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2590,6 +2590,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): use_google_kms: bool | None = Field(None, description="decrypt keys with google kms") use_azure_key_vault: bool | None = Field(None, description="load keys from azure key vault") master_key: str | None = Field(None, description="require a key for all calls to proxy") + dangerously_allow_unsafe_proxy: bool | None = Field( + None, + description="local development only: start even when master_key is unset, empty, or a publicly known default", + ) coordination_redis: CoordinationRedisParams | None = Field( None, description=( diff --git a/litellm/proxy/auth/master_key_boot_check.py b/litellm/proxy/auth/master_key_boot_check.py new file mode 100644 index 00000000000..725798c5bb3 --- /dev/null +++ b/litellm/proxy/auth/master_key_boot_check.py @@ -0,0 +1,221 @@ +"""Decides at boot whether the proxy may start with the master key it resolved.""" + +import atexit +import sys +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from enum import Enum +from types import MappingProxyType +from typing import Final, assert_never + +from litellm._logging import verbose_proxy_logger + +UNSAFE_PROXY_OVERRIDE_SETTING: Final = "dangerously_allow_unsafe_proxy" +UNSAFE_PROXY_OVERRIDE_ENV_VAR: Final = "LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY" +MASTER_KEY_SETTING: Final = "master_key" +MASTER_KEY_ENV_VAR: Final = "LITELLM_MASTER_KEY" +SALT_KEY_ENV_VAR: Final = "LITELLM_SALT_KEY" +PUBLICLY_KNOWN_MASTER_KEYS: Final = frozenset({"sk-1234"}) +ROTATION_DOCS_URL: Final = "https://docs.litellm.ai/docs/proxy/master_key_rotations#proxy-refuses-to-start" +GENERATE_MASTER_KEY_COMMAND: Final = f'echo "{MASTER_KEY_ENV_VAR}=sk-$(openssl rand -hex 32)" | tee -a .env' + + +class UnsafeMasterKeyReason(Enum): + NOT_SET = "not_set" + EMPTY = "empty" + PUBLICLY_KNOWN = "publicly_known" + + +@dataclass(frozen=True, slots=True) +class ConfigFileSource: + config_file_path: str | None + + +@dataclass(frozen=True, slots=True) +class EnvironmentSource: + pass + + +MasterKeySource = ConfigFileSource | EnvironmentSource + + +@dataclass(frozen=True, slots=True) +class SafeMasterKey: + pass + + +@dataclass(frozen=True, slots=True) +class UnsafeMasterKeyAllowed: + reason: UnsafeMasterKeyReason + + +@dataclass(frozen=True, slots=True) +class UnsafeMasterKeyRefused: + reason: UnsafeMasterKeyReason + source: MasterKeySource + stored_credentials_need_rotation: bool + + +MasterKeyBootVerdict = SafeMasterKey | UnsafeMasterKeyAllowed | UnsafeMasterKeyRefused + + +class UnsafeMasterKeyError(Exception): + pass + + +def master_key_boot_verdict( + *, + master_key: str | None, + environment_master_key: str | None, + general_settings: Mapping[str, object], + config_file_path: str | None, + override_env_is_on: bool, + salt_key_is_set: bool, + database_is_configured: bool, +) -> MasterKeyBootVerdict: + reason: Final = _unsafe_reason(master_key) + if reason is None: + return SafeMasterKey() + if override_env_is_on or general_settings.get(UNSAFE_PROXY_OVERRIDE_SETTING) is True: + return UnsafeMasterKeyAllowed(reason=reason) + config_file_only_relays_the_environment: Final = master_key is not None and master_key == environment_master_key + return UnsafeMasterKeyRefused( + reason=reason, + source=( + ConfigFileSource(config_file_path=config_file_path) + if MASTER_KEY_SETTING in general_settings and not config_file_only_relays_the_environment + else EnvironmentSource() + ), + stored_credentials_need_rotation=( + reason is UnsafeMasterKeyReason.PUBLICLY_KNOWN and not salt_key_is_set and database_is_configured + ), + ) + + +def enforce_master_key_boot_verdict(verdict: MasterKeyBootVerdict, announce: Callable[[str], object]) -> None: + match verdict: + case SafeMasterKey(): + return + case UnsafeMasterKeyAllowed(reason=reason): + verbose_proxy_logger.warning( + "%s is on, so the proxy is starting with %s. Never run this outside local development.", + UNSAFE_PROXY_OVERRIDE_SETTING, + _UNSAFE_STATE[reason], + ) + case UnsafeMasterKeyRefused(reason=reason): + announce(f"\n{render_refusal(verdict)}\n\n") + raise UnsafeMasterKeyError( + f"LiteLLM proxy refused to start: {_REFUSAL_HEADLINE[reason]} The fix is printed once the server exits." + ) + case _: + assert_never(verdict) + + +def announce_on_stderr_at_exit(message: str) -> None: + """Keeps the fix as the last thing on screen and away from the log handlers. + + A failed lifespan prints a traceback hundreds of lines long (a frame pair per included router) that buries + anything written before it, and the log redactor strips the key-shaped command from anything sent to a logger. + """ + atexit.register(_flush_stdout_then_write_stderr, message) + + +def _flush_stdout_then_write_stderr(message: str) -> None: + sys.stdout.flush() + sys.stderr.write(message) + + +def render_refusal(refusal: UnsafeMasterKeyRefused) -> str: + return "\n\n".join( + ( + f"LiteLLM proxy refused to start: {_REFUSAL_HEADLINE[refusal.reason]}\n{_source_line(refusal)}", + _fix_steps(refusal.source), + *((_ROTATION_WARNING,) if refusal.stored_credentials_need_rotation else ()), + _OVERRIDE_HINT, + ) + ) + + +_UNSAFE_STATE: Final = MappingProxyType( + { + UnsafeMasterKeyReason.NOT_SET: "no master key, which accepts every request without authentication", + UnsafeMasterKeyReason.EMPTY: "an empty master key", + UnsafeMasterKeyReason.PUBLICLY_KNOWN: "a publicly known master key", + } +) + +_REFUSAL_HEADLINE: Final = MappingProxyType( + { + UnsafeMasterKeyReason.NOT_SET: ( + "no master key is set, so every request would be accepted without authentication." + ), + UnsafeMasterKeyReason.EMPTY: "the master key is empty.", + UnsafeMasterKeyReason.PUBLICLY_KNOWN: "the master key is a publicly known default.", + } +) + +_SAVE_KEY_STEP: Final = ( + "Generate a key and save it to .env:\n" + f" {GENERATE_MASTER_KEY_COMMAND}\n" + " Not using a .env file (docker run, Kubernetes, pip install)? Pass the same value as the\n" + f" {MASTER_KEY_ENV_VAR} environment variable instead." +) + +_ROTATION_WARNING: Final = ( + f"Credentials stored in your database are encrypted with the current master key because {SALT_KEY_ENV_VAR}\n" + f"is not set. Rotate the key before changing it, or they become undecryptable:\n{ROTATION_DOCS_URL}" +) + +_OVERRIDE_HINT: Final = ( + f"Local development only: set {UNSAFE_PROXY_OVERRIDE_ENV_VAR}=true, or\n" + f"general_settings.{UNSAFE_PROXY_OVERRIDE_SETTING}: true, to start anyway." +) + + +def _unsafe_reason(master_key: str | None) -> UnsafeMasterKeyReason | None: + if master_key is None: + return UnsafeMasterKeyReason.NOT_SET + stripped: Final = master_key.strip() + if not stripped: + return UnsafeMasterKeyReason.EMPTY + if stripped in PUBLICLY_KNOWN_MASTER_KEYS: + return UnsafeMasterKeyReason.PUBLICLY_KNOWN + return None + + +def _config_label(source: ConfigFileSource) -> str: + return source.config_file_path or "your config" + + +def _source_line(refusal: UnsafeMasterKeyRefused) -> str: + match refusal.source: + case ConfigFileSource() as source: + if refusal.reason is UnsafeMasterKeyReason.NOT_SET: + return ( + f"general_settings.{MASTER_KEY_SETTING} in {_config_label(source)} is blank, or points at an " + "environment variable that is not set." + ) + return f"It comes from general_settings.{MASTER_KEY_SETTING} in {_config_label(source)}." + case EnvironmentSource(): + if refusal.reason is UnsafeMasterKeyReason.NOT_SET: + return ( + f"Neither general_settings.{MASTER_KEY_SETTING} nor the {MASTER_KEY_ENV_VAR} " + "environment variable is set." + ) + return f"It comes from the {MASTER_KEY_ENV_VAR} environment variable." + case _: + assert_never(refusal.source) + + +def _fix_steps(source: MasterKeySource) -> str: + match source: + case ConfigFileSource(): + return ( + f"1. Make {_config_label(source)} read the key from the environment:\n" + f" general_settings:\n {MASTER_KEY_SETTING}: os.environ/{MASTER_KEY_ENV_VAR}\n" + f"2. {_SAVE_KEY_STEP}" + ) + case EnvironmentSource(): + return f"1. {_SAVE_KEY_STEP}" + case _: + assert_never(source) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 3c7d06268ad..6c734186faf 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -342,6 +342,14 @@ from litellm.proxy.auth.login_throttle import ( warn_login_counters_are_per_worker, warn_source_login_limit_is_off, ) +from litellm.proxy.auth.master_key_boot_check import ( + MASTER_KEY_ENV_VAR, + SALT_KEY_ENV_VAR, + UNSAFE_PROXY_OVERRIDE_ENV_VAR, + announce_on_stderr_at_exit, + enforce_master_key_boot_verdict, + master_key_boot_verdict, +) from litellm.proxy.auth.model_checks import ( expand_wildcard_deployments_for_model_info, get_all_fallbacks, @@ -1215,6 +1223,19 @@ async def proxy_startup_event(app: FastAPI) -> AsyncGenerator[None, None]: if isinstance(worker_config, dict): await initialize(**worker_config) + enforce_master_key_boot_verdict( + master_key_boot_verdict( + master_key=master_key, + environment_master_key=os.getenv(MASTER_KEY_ENV_VAR), + general_settings=general_settings, + config_file_path=user_config_file_path, + override_env_is_on=get_secret_bool(UNSAFE_PROXY_OVERRIDE_ENV_VAR) is True, + salt_key_is_set=os.getenv(SALT_KEY_ENV_VAR) is not None, + database_is_configured=prisma_client is not None or get_secret("DATABASE_URL", None) is not None, + ), + announce=announce_on_stderr_at_exit, + ) + # check if DATABASE_URL in environment - load from there if prisma_client is None: _db_url: Final[str | None] = get_secret("DATABASE_URL", None) diff --git a/render.yaml b/render.yaml index 18ad8ff2078..b61385c4d90 100644 --- a/render.yaml +++ b/render.yaml @@ -7,6 +7,8 @@ services: envVars: - key: PORT value: 4000 + - key: LITELLM_MASTER_KEY + generateValue: true numInstances: 1 healthCheckPath: /health/liveliness autoDeploy: true diff --git a/tests/e2e/ui/run_e2e.sh b/tests/e2e/ui/run_e2e.sh index beb1bc8bf3b..c5a992e4527 100755 --- a/tests/e2e/ui/run_e2e.sh +++ b/tests/e2e/ui/run_e2e.sh @@ -145,6 +145,7 @@ fi # --- Credentials --- export LITELLM_MASTER_KEY="sk-1234" +export LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY="true" export MOCK_LLM_URL="http://127.0.0.1:${MOCK_LLM_PORT}/v1" export E2E_MOCK_PRESIDIO_URL="http://127.0.0.1:${MOCK_PRESIDIO_PORT}" export DISABLE_SCHEMA_UPDATE="true" diff --git a/tests/proxy_behavior/management/conftest.py b/tests/proxy_behavior/management/conftest.py index 4c5b2ee9949..255b937bdd3 100644 --- a/tests/proxy_behavior/management/conftest.py +++ b/tests/proxy_behavior/management/conftest.py @@ -13,7 +13,7 @@ from prisma import Json from litellm.proxy.utils import hash_token -MASTER_KEY = "sk-1234" +MASTER_KEY = "sk-proxy-behavior-master-key" SCRATCH_PREFIX = "scratch-" diff --git a/tests/proxy_security_tests/test_master_key_not_in_db.py b/tests/proxy_security_tests/test_master_key_not_in_db.py index cb6e08d6746..df0834aa884 100644 --- a/tests/proxy_security_tests/test_master_key_not_in_db.py +++ b/tests/proxy_security_tests/test_master_key_not_in_db.py @@ -20,8 +20,10 @@ def override_env_settings(monkeypatch): @pytest.fixture(scope="module") def test_client(): """Starting the test client triggers FastAPI startup, where Prisma connects to the DB.""" - with TestClient(app) as client: - yield client + with pytest.MonkeyPatch.context() as boot_env: + boot_env.setenv("LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY", "true") + with TestClient(app) as client: + yield client @pytest.mark.asyncio diff --git a/tests/proxy_unit_tests/test_aproxy_startup.py b/tests/proxy_unit_tests/test_aproxy_startup.py index 98bf6ef8eb7..f2e1d0a521d 100644 --- a/tests/proxy_unit_tests/test_aproxy_startup.py +++ b/tests/proxy_unit_tests/test_aproxy_startup.py @@ -22,7 +22,7 @@ from litellm.proxy.proxy_server import ( @pytest.mark.asyncio -async def test_proxy_gunicorn_startup_direct_config(): +async def test_proxy_gunicorn_startup_direct_config(monkeypatch): """ gunicorn startup requires the config to be passed in via environment variables @@ -30,6 +30,7 @@ async def test_proxy_gunicorn_startup_direct_config(): Test both approaches """ + monkeypatch.setenv("LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY", "true") try: from litellm._logging import verbose_proxy_logger, verbose_router_logger import logging @@ -59,7 +60,8 @@ async def test_proxy_gunicorn_startup_direct_config(): @pytest.mark.asyncio -async def test_proxy_gunicorn_startup_config_dict(): +async def test_proxy_gunicorn_startup_config_dict(monkeypatch): + monkeypatch.setenv("LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY", "true") try: from litellm._logging import verbose_proxy_logger, verbose_router_logger import logging diff --git a/tests/test_litellm/proxy/auth/test_master_key_boot_check.py b/tests/test_litellm/proxy/auth/test_master_key_boot_check.py new file mode 100644 index 00000000000..0bca702f73f --- /dev/null +++ b/tests/test_litellm/proxy/auth/test_master_key_boot_check.py @@ -0,0 +1,221 @@ +import re +import shutil +import subprocess +from collections.abc import Mapping +from pathlib import Path + +import pytest + +from litellm.proxy.auth.master_key_boot_check import ( + GENERATE_MASTER_KEY_COMMAND, + MASTER_KEY_ENV_VAR, + ROTATION_DOCS_URL, + UNSAFE_PROXY_OVERRIDE_ENV_VAR, + UNSAFE_PROXY_OVERRIDE_SETTING, + ConfigFileSource, + EnvironmentSource, + MasterKeyBootVerdict, + SafeMasterKey, + UnsafeMasterKeyAllowed, + UnsafeMasterKeyError, + UnsafeMasterKeyReason, + UnsafeMasterKeyRefused, + enforce_master_key_boot_verdict, + master_key_boot_verdict, + render_refusal, +) + + +def _verdict( + master_key: str | None, + general_settings: Mapping[str, object] | None = None, + *, + environment_master_key: str | None = None, + config_file_path: str | None = None, + override_env_is_on: bool = False, + salt_key_is_set: bool = False, + database_is_configured: bool = False, +) -> MasterKeyBootVerdict: + return master_key_boot_verdict( + master_key=master_key, + environment_master_key=environment_master_key, + general_settings=general_settings or {}, + config_file_path=config_file_path, + override_env_is_on=override_env_is_on, + salt_key_is_set=salt_key_is_set, + database_is_configured=database_is_configured, + ) + + +@pytest.mark.parametrize( + ("master_key", "reason"), + [ + (None, UnsafeMasterKeyReason.NOT_SET), + ("", UnsafeMasterKeyReason.EMPTY), + (" \t\n", UnsafeMasterKeyReason.EMPTY), + ("sk-1234", UnsafeMasterKeyReason.PUBLICLY_KNOWN), + (" sk-1234\n", UnsafeMasterKeyReason.PUBLICLY_KNOWN), + ], +) +def test_unsafe_master_keys_are_refused_with_their_reason(master_key: str | None, reason: UnsafeMasterKeyReason): + verdict = _verdict(master_key) + + assert isinstance(verdict, UnsafeMasterKeyRefused) + assert verdict.reason is reason + + +@pytest.mark.parametrize("master_key", ["sk-12345", "sk-1234567890", "1234", "sk-qa-9f2c1e7a44b0d3"]) +def test_keys_that_only_resemble_the_known_default_are_safe(master_key: str): + assert _verdict(master_key) == SafeMasterKey() + + +@pytest.mark.parametrize("master_key", [None, "", "sk-1234"]) +def test_either_override_lets_an_unsafe_key_through(master_key: str | None): + from_env = _verdict(master_key, override_env_is_on=True) + from_yaml = _verdict(master_key, {UNSAFE_PROXY_OVERRIDE_SETTING: True}) + + assert isinstance(from_env, UnsafeMasterKeyAllowed) + assert from_env == from_yaml + + +def test_override_switched_off_in_yaml_still_refuses(): + assert isinstance(_verdict("sk-1234", {UNSAFE_PROXY_OVERRIDE_SETTING: False}), UnsafeMasterKeyRefused) + + +def test_yaml_master_key_is_the_source_even_when_it_resolved_to_nothing(): + verdict = _verdict(None, {"master_key": None}, config_file_path="/app/config.yaml") + + assert isinstance(verdict, UnsafeMasterKeyRefused) + assert verdict.source == ConfigFileSource(config_file_path="/app/config.yaml") + + +def test_yaml_master_key_is_the_source_when_it_differs_from_the_environment(): + verdict = _verdict( + "sk-1234", + {"master_key": "sk-1234"}, + environment_master_key="sk-qa-9f2c1e7a44b0d3", + config_file_path="/app/config.yaml", + ) + + assert isinstance(verdict, UnsafeMasterKeyRefused) + assert verdict.source == ConfigFileSource(config_file_path="/app/config.yaml") + + +@pytest.mark.parametrize("unsafe_key", ["sk-1234", ""]) +def test_environment_is_the_source_when_yaml_only_relays_the_environment_variable(unsafe_key: str): + verdict = _verdict( + unsafe_key, + {"master_key": unsafe_key}, + environment_master_key=unsafe_key, + config_file_path="/app/config.yaml", + ) + + assert isinstance(verdict, UnsafeMasterKeyRefused) + assert verdict.source == EnvironmentSource() + + +def test_environment_is_the_source_when_yaml_does_not_set_a_master_key(): + verdict = _verdict("sk-1234", {"database_url": "postgresql://db"}, config_file_path="/app/config.yaml") + + assert isinstance(verdict, UnsafeMasterKeyRefused) + assert verdict.source == EnvironmentSource() + + +@pytest.mark.parametrize( + ("master_key", "salt_key_is_set", "database_is_configured", "needs_rotation"), + [ + ("sk-1234", False, True, True), + ("sk-1234", True, True, False), + ("sk-1234", False, False, False), + (None, False, True, False), + ("", False, True, False), + ], +) +def test_rotation_is_only_needed_when_the_known_key_encrypts_a_database( + master_key: str | None, salt_key_is_set: bool, database_is_configured: bool, needs_rotation: bool +): + verdict = _verdict(master_key, salt_key_is_set=salt_key_is_set, database_is_configured=database_is_configured) + + assert isinstance(verdict, UnsafeMasterKeyRefused) + assert verdict.stored_credentials_need_rotation is needs_rotation + + +def _refusal( + reason: UnsafeMasterKeyReason = UnsafeMasterKeyReason.PUBLICLY_KNOWN, + source: ConfigFileSource | EnvironmentSource = EnvironmentSource(), + stored_credentials_need_rotation: bool = False, +) -> UnsafeMasterKeyRefused: + return UnsafeMasterKeyRefused( + reason=reason, source=source, stored_credentials_need_rotation=stored_credentials_need_rotation + ) + + +def test_config_refusal_names_the_file_and_tells_it_to_read_the_environment(): + text = render_refusal(_refusal(source=ConfigFileSource(config_file_path="/app/config.yaml"))) + + assert "general_settings.master_key in /app/config.yaml" in text + assert f"master_key: os.environ/{MASTER_KEY_ENV_VAR}" in text + assert GENERATE_MASTER_KEY_COMMAND in text + + +def test_environment_refusal_gives_the_command_without_a_config_step(): + text = render_refusal(_refusal(source=EnvironmentSource())) + + assert f"the {MASTER_KEY_ENV_VAR} environment variable" in text + assert GENERATE_MASTER_KEY_COMMAND in text + assert "os.environ/" not in text + + +def test_unset_key_refusal_says_nothing_supplied_one(): + text = render_refusal(_refusal(reason=UnsafeMasterKeyReason.NOT_SET, source=EnvironmentSource())) + + assert "Neither general_settings.master_key nor" in text + + +def test_rotation_warning_appears_only_when_needed(): + with_rotation = render_refusal(_refusal(stored_credentials_need_rotation=True)) + without_rotation = render_refusal(_refusal(stored_credentials_need_rotation=False)) + + assert ROTATION_DOCS_URL in with_rotation + assert ROTATION_DOCS_URL not in without_rotation + + +@pytest.mark.parametrize("stored_credentials_need_rotation", [True, False]) +def test_override_hint_is_the_last_paragraph(stored_credentials_need_rotation: bool): + text = render_refusal(_refusal(stored_credentials_need_rotation=stored_credentials_need_rotation)) + last_paragraph = text.split("\n\n")[-1] + + assert UNSAFE_PROXY_OVERRIDE_ENV_VAR in last_paragraph + assert f"general_settings.{UNSAFE_PROXY_OVERRIDE_SETTING}" in last_paragraph + + +@pytest.mark.skipif(shutil.which("openssl") is None, reason="the printed command shells out to openssl") +def test_printed_command_saves_a_key_the_boot_check_accepts(tmp_path: Path): + completed = subprocess.run( + ["bash", "-c", GENERATE_MASTER_KEY_COMMAND], cwd=tmp_path, capture_output=True, text=True, check=True + ) + + saved = (tmp_path / ".env").read_text() + match = re.fullmatch(rf"{MASTER_KEY_ENV_VAR}=(sk-[0-9a-f]{{64}})\n", saved) + assert match is not None + assert completed.stdout == saved + assert _verdict(match.group(1)) == SafeMasterKey() + + +def test_refusal_announces_the_fix_and_aborts_the_boot(): + announced: list[str] = [] + refusal = _refusal(source=ConfigFileSource(config_file_path="/app/config.yaml")) + + with pytest.raises(UnsafeMasterKeyError, match="refused to start"): + enforce_master_key_boot_verdict(refusal, announce=announced.append) + + assert [message.strip() for message in announced] == [render_refusal(refusal)] + + +@pytest.mark.parametrize("verdict", [SafeMasterKey(), UnsafeMasterKeyAllowed(reason=UnsafeMasterKeyReason.NOT_SET)]) +def test_safe_and_overridden_keys_boot_without_announcing(verdict: MasterKeyBootVerdict): + announced: list[str] = [] + + enforce_master_key_boot_verdict(verdict, announce=announced.append) + + assert announced == [] diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 935cc6ad8b7..0d440dd14eb 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -1628,6 +1628,83 @@ async def test_aaaproxy_startup_master_key(mock_prisma, monkeypatch, tmp_path): assert master_key == test_resolved_key +def _boot_with_general_settings(monkeypatch, tmp_path, general_settings): + import yaml + + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.dump({"general_settings": general_settings})) + for name in ("LITELLM_MASTER_KEY", "LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY", "WORKER_CONFIG", "DATABASE_URL"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("CONFIG_FILE_PATH", str(config_path)) + announced = [] + monkeypatch.setattr("litellm.proxy.proxy_server.announce_on_stderr_at_exit", announced.append) + return config_path, announced + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "general_settings", + [{"master_key": "sk-1234"}, {"master_key": ""}, {"master_key": None}, {}], + ids=["publicly-known", "empty", "yaml-null", "no-general-settings"], +) +async def test_proxy_startup_refuses_an_unsafe_master_key_before_connecting_to_the_database( + monkeypatch, tmp_path, general_settings +): + from fastapi import FastAPI + + from litellm.proxy.auth.master_key_boot_check import UnsafeMasterKeyError + from litellm.proxy.proxy_server import proxy_startup_event + + _, announced = _boot_with_general_settings(monkeypatch, tmp_path, general_settings) + monkeypatch.setenv("DATABASE_URL", "postgresql://nobody:nothing@127.0.0.1:1/unreachable") + + with pytest.raises(UnsafeMasterKeyError): + async with proxy_startup_event(FastAPI()): + pass + + assert len(announced) == 1 + assert "LITELLM_MASTER_KEY=sk-$(openssl rand -hex 32)" in announced[0] + + +@pytest.mark.asyncio +async def test_proxy_startup_names_the_config_file_that_set_the_unsafe_key(monkeypatch, tmp_path): + from fastapi import FastAPI + + from litellm.proxy.auth.master_key_boot_check import UnsafeMasterKeyError + from litellm.proxy.proxy_server import proxy_startup_event + + config_path, announced = _boot_with_general_settings(monkeypatch, tmp_path, {"master_key": "sk-1234"}) + + with pytest.raises(UnsafeMasterKeyError): + async with proxy_startup_event(FastAPI()): + pass + + assert str(config_path) in announced[0] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("override", ["yaml", "env"]) +async def test_proxy_startup_boots_an_unsafe_master_key_under_the_override(monkeypatch, tmp_path, override): + from fastapi import FastAPI + + from litellm.proxy.proxy_server import proxy_startup_event + + general_settings = { + "master_key": "sk-1234", + **({"dangerously_allow_unsafe_proxy": True} if override == "yaml" else {}), + } + _, announced = _boot_with_general_settings(monkeypatch, tmp_path, general_settings) + if override == "env": + monkeypatch.setenv("LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY", "true") + + async with proxy_startup_event(FastAPI()): + from litellm.proxy.proxy_server import master_key + + assert master_key == "sk-1234" + + assert announced == [] + + def test_team_info_masking(): """ Test that sensitive team information is properly masked diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 7aa34c5752c..1b62e59054d 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -26727,6 +26727,11 @@ export interface components { * @description override user_api_key_auth with your own auth script - https://docs.litellm.ai/docs/proxy/virtual_keys#custom-auth */ custom_auth?: string | null; + /** + * Dangerously Allow Unsafe Proxy + * @description local development only: start even when master_key is unset, empty, or a publicly known default + */ + dangerously_allow_unsafe_proxy?: boolean | null; /** @description custom args for instantiating dynamodb client - e.g. billing provision */ database_args?: components["schemas"]["DynamoDBArgs"] | null; /**