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
This commit is contained in:
ryan-crabbe-berri 2026-09-19 13:44:00 -07:00
parent b946d12ffd
commit fe480533e8
13 changed files with 578 additions and 6 deletions

View file

@ -1650,6 +1650,7 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4001:4000 \ -p 4001:4000 \
-e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \
-e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/litellm_test" \ -e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/litellm_test" \
-e LITELLM_MASTER_KEY="sk-1234" \ -e LITELLM_MASTER_KEY="sk-1234" \
--name schema-seed \ --name schema-seed \
@ -1670,6 +1671,7 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \
-e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/litellm_test" \ -e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/litellm_test" \
-e DEFAULT_NUM_WORKERS_LITELLM_PROXY=1 \ -e DEFAULT_NUM_WORKERS_LITELLM_PROXY=1 \
-e DISABLE_SCHEMA_UPDATE="True" \ -e DISABLE_SCHEMA_UPDATE="True" \
@ -1744,6 +1746,7 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e USE_PRISMA_MIGRATE=True \ -e USE_PRISMA_MIGRATE=True \
-e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
@ -1839,6 +1842,7 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e AZURE_API_KEY=$AZURE_API_KEY \ -e AZURE_API_KEY=$AZURE_API_KEY \
-e AZURE_API_BASE=$AZURE_API_BASE \ -e AZURE_API_BASE=$AZURE_API_BASE \
@ -1927,6 +1931,7 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e REDIS_HOST=$REDIS_HOST \ -e REDIS_HOST=$REDIS_HOST \
-e REDIS_PASSWORD=$REDIS_PASSWORD \ -e REDIS_PASSWORD=$REDIS_PASSWORD \
@ -1987,6 +1992,7 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e REDIS_HOST=$REDIS_HOST \ -e REDIS_HOST=$REDIS_HOST \
-e REDIS_PASSWORD=$REDIS_PASSWORD \ -e REDIS_PASSWORD=$REDIS_PASSWORD \
@ -2064,6 +2070,7 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e REDIS_HOST=host.docker.internal \ -e REDIS_HOST=host.docker.internal \
-e REDIS_PORT=6379 \ -e REDIS_PORT=6379 \
@ -2146,6 +2153,7 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e REDIS_HOST=$REDIS_HOST \ -e REDIS_HOST=$REDIS_HOST \
-e REDIS_PASSWORD=$REDIS_PASSWORD \ -e REDIS_PASSWORD=$REDIS_PASSWORD \
@ -2168,6 +2176,7 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4001:4001 \ -p 4001:4001 \
-e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e REDIS_HOST=$REDIS_HOST \ -e REDIS_HOST=$REDIS_HOST \
-e REDIS_PASSWORD=$REDIS_PASSWORD \ -e REDIS_PASSWORD=$REDIS_PASSWORD \
@ -2245,6 +2254,7 @@ jobs:
docker run -d \ docker run -d \
--restart on-failure \ --restart on-failure \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e STORE_MODEL_IN_DB="True" \ -e STORE_MODEL_IN_DB="True" \
-e LITELLM_MASTER_KEY="sk-1234" \ -e LITELLM_MASTER_KEY="sk-1234" \
@ -2319,6 +2329,7 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e REDIS_HOST=$REDIS_HOST \ -e REDIS_HOST=$REDIS_HOST \
-e REDIS_PASSWORD=$REDIS_PASSWORD \ -e REDIS_PASSWORD=$REDIS_PASSWORD \
@ -2401,6 +2412,7 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e LITELLM_MASTER_KEY="sk-1234" \ -e LITELLM_MASTER_KEY="sk-1234" \
-e OPENAI_API_KEY=$OPENAI_API_KEY \ -e OPENAI_API_KEY=$OPENAI_API_KEY \
@ -2492,6 +2504,7 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e LITELLM_MASTER_KEY="sk-1234" \ -e LITELLM_MASTER_KEY="sk-1234" \
-e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \ -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \
@ -2673,6 +2686,7 @@ jobs:
name: Start LiteLLM proxy name: Start LiteLLM proxy
environment: environment:
LITELLM_MASTER_KEY: "sk-1234" LITELLM_MASTER_KEY: "sk-1234"
LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY: "true"
MOCK_LLM_URL: "http://127.0.0.1:8090/v1" MOCK_LLM_URL: "http://127.0.0.1:8090/v1"
DISABLE_SCHEMA_UPDATE: "true" DISABLE_SCHEMA_UPDATE: "true"
SERVER_ROOT_PATH: "" SERVER_ROOT_PATH: ""
@ -2816,6 +2830,7 @@ jobs:
name: Start LiteLLM proxy under a server root path name: Start LiteLLM proxy under a server root path
environment: environment:
LITELLM_MASTER_KEY: "sk-1234" LITELLM_MASTER_KEY: "sk-1234"
LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY: "true"
MOCK_LLM_URL: "http://127.0.0.1:8090/v1" MOCK_LLM_URL: "http://127.0.0.1:8090/v1"
DISABLE_SCHEMA_UPDATE: "true" DISABLE_SCHEMA_UPDATE: "true"
# Output flows to this step's own log, so a boot crash is visible here # Output flows to this step's own log, so a boot crash is visible here
@ -2901,6 +2916,7 @@ jobs:
command: | command: |
docker run --name my-app \ docker run --name my-app \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \
-e DEFAULT_NUM_WORKERS_LITELLM_PROXY=1 \ -e DEFAULT_NUM_WORKERS_LITELLM_PROXY=1 \
-e DATABASE_URL="postgresql://wrong:wrong@wrong:5432/wrong" \ -e DATABASE_URL="postgresql://wrong:wrong@wrong:5432/wrong" \
myapp:latest \ myapp:latest \

View file

@ -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": { "500": {
"content": { "content": {

View file

@ -2590,6 +2590,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
use_google_kms: bool | None = Field(None, description="decrypt keys with google kms") 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") 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") 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( coordination_redis: CoordinationRedisParams | None = Field(
None, None,
description=( description=(

View file

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

View file

@ -342,6 +342,14 @@ from litellm.proxy.auth.login_throttle import (
warn_login_counters_are_per_worker, warn_login_counters_are_per_worker,
warn_source_login_limit_is_off, 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 ( from litellm.proxy.auth.model_checks import (
expand_wildcard_deployments_for_model_info, expand_wildcard_deployments_for_model_info,
get_all_fallbacks, get_all_fallbacks,
@ -1215,6 +1223,19 @@ async def proxy_startup_event(app: FastAPI) -> AsyncGenerator[None, None]:
if isinstance(worker_config, dict): if isinstance(worker_config, dict):
await initialize(**worker_config) 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 # check if DATABASE_URL in environment - load from there
if prisma_client is None: if prisma_client is None:
_db_url: Final[str | None] = get_secret("DATABASE_URL", None) _db_url: Final[str | None] = get_secret("DATABASE_URL", None)

View file

@ -7,6 +7,8 @@ services:
envVars: envVars:
- key: PORT - key: PORT
value: 4000 value: 4000
- key: LITELLM_MASTER_KEY
generateValue: true
numInstances: 1 numInstances: 1
healthCheckPath: /health/liveliness healthCheckPath: /health/liveliness
autoDeploy: true autoDeploy: true

View file

@ -145,6 +145,7 @@ fi
# --- Credentials --- # --- Credentials ---
export LITELLM_MASTER_KEY="sk-1234" 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 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 E2E_MOCK_PRESIDIO_URL="http://127.0.0.1:${MOCK_PRESIDIO_PORT}"
export DISABLE_SCHEMA_UPDATE="true" export DISABLE_SCHEMA_UPDATE="true"

View file

@ -13,7 +13,7 @@ from prisma import Json
from litellm.proxy.utils import hash_token from litellm.proxy.utils import hash_token
MASTER_KEY = "sk-1234" MASTER_KEY = "sk-proxy-behavior-master-key"
SCRATCH_PREFIX = "scratch-" SCRATCH_PREFIX = "scratch-"

View file

@ -20,8 +20,10 @@ def override_env_settings(monkeypatch):
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def test_client(): def test_client():
"""Starting the test client triggers FastAPI startup, where Prisma connects to the DB.""" """Starting the test client triggers FastAPI startup, where Prisma connects to the DB."""
with TestClient(app) as client: with pytest.MonkeyPatch.context() as boot_env:
yield client boot_env.setenv("LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY", "true")
with TestClient(app) as client:
yield client
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -22,7 +22,7 @@ from litellm.proxy.proxy_server import (
@pytest.mark.asyncio @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 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 Test both approaches
""" """
monkeypatch.setenv("LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY", "true")
try: try:
from litellm._logging import verbose_proxy_logger, verbose_router_logger from litellm._logging import verbose_proxy_logger, verbose_router_logger
import logging import logging
@ -59,7 +60,8 @@ async def test_proxy_gunicorn_startup_direct_config():
@pytest.mark.asyncio @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: try:
from litellm._logging import verbose_proxy_logger, verbose_router_logger from litellm._logging import verbose_proxy_logger, verbose_router_logger
import logging import logging

View file

@ -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 == []

View file

@ -1628,6 +1628,83 @@ async def test_aaaproxy_startup_master_key(mock_prisma, monkeypatch, tmp_path):
assert master_key == test_resolved_key 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(): def test_team_info_masking():
""" """
Test that sensitive team information is properly masked Test that sensitive team information is properly masked

View file

@ -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 * @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; 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 */ /** @description custom args for instantiating dynamodb client - e.g. billing provision */
database_args?: components["schemas"]["DynamoDBArgs"] | null; database_args?: components["schemas"]["DynamoDBArgs"] | null;
/** /**