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: |
|
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 \
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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=(
|
||||||
|
|
|
||||||
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_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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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-"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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
|
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
|
||||||
|
|
|
||||||
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
|
* @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;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue