mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
parent
b946d12ffd
commit
fe480533e8
13 changed files with 578 additions and 6 deletions
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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=(
|
||||
|
|
|
|||
221
litellm/proxy/auth/master_key_boot_check.py
Normal file
221
litellm/proxy/auth/master_key_boot_check.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ services:
|
|||
envVars:
|
||||
- key: PORT
|
||||
value: 4000
|
||||
- key: LITELLM_MASTER_KEY
|
||||
generateValue: true
|
||||
numInstances: 1
|
||||
healthCheckPath: /health/liveliness
|
||||
autoDeploy: true
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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-"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
221
tests/test_litellm/proxy/auth/test_master_key_boot_check.py
Normal file
221
tests/test_litellm/proxy/auth/test_master_key_boot_check.py
Normal 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 == []
|
||||
|
|
@ -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
|
||||
|
|
|
|||
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue