Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1650,6 +1650,7 @@ jobs:
command: |
docker run -d \
-p 4001:4000 \
-e LITELLM_DANGEROUSLY_ALLOW_UNSAFE_PROXY=true \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: LITELLM_DANGEROUSLY_PERMIT_WEAK_MASTER_KEY=true <- I feel like this is more descriptive

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed in ecf1751 to LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY (YAML dangerously_permit_weak_or_unset_master_key), since the override also covers an unset or empty key, not only a weak one

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

late to the party and bit of a nit, but we explicitly are not checking key strength, so calling it "weak" is a misnomer. I think LITELLM_DANGEROUSLY_PERMIT_DEFAULT_OR_UNSET_MASTER_KEY would have been better, but I imagine changing this after merge is a lot more trouble than it's worth

-e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/litellm_test" \
-e LITELLM_MASTER_KEY="sk-1234" \
--name schema-seed \
Expand All @@ -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" \
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand All @@ -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 \
Expand Down Expand Up @@ -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" \
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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: ""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
4 changes: 4 additions & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=(
Expand Down
227 changes: 227 additions & 0 deletions litellm/proxy/auth/master_key_boot_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
"""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

from typing_extensions import 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"
_NEW_MASTER_KEY: Final = "sk-$(openssl rand -hex 32)"
GENERATE_MASTER_KEY_COMMAND: Final = f'echo "{MASTER_KEY_ENV_VAR}={_NEW_MASTER_KEY}" | tee -a .env'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe use echo "${MASTER_KEY_ENV_VAR}=${_NEW_MASTER_KEY}" >> .env so it doesn't emit the master key to stdout for the user...?

PRINT_NEW_MASTER_KEY_COMMAND: Final = f'echo "{_NEW_MASTER_KEY}"'


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
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
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)}",
_ROTATE_INSTEAD_OF_REPLACING if refusal.stored_credentials_need_rotation else _fix_steps(refusal.source),
_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"
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
" 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."
)

_ROTATE_INSTEAD_OF_REPLACING: Final = (
f"Credentials stored in your database are encrypted with this master key because {SALT_KEY_ENV_VAR} is not\n"
"set, so replacing the key makes them undecryptable. Rotate it by following this guide, which re-encrypts them:\n"
f" {ROTATION_DOCS_URL}\n"
"Generate the new key for it with (save it only once the guide says to):\n"
f" {PRINT_NEW_MASTER_KEY_COMMAND}"
)

_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:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
match source:
case ConfigFileSource():
return (
f"1. Make sure {_config_label(source)} reads 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)
Loading
Loading