Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions litellm/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -1496,6 +1496,7 @@
MAX_POLICY_ESTIMATE_IMPACT_ROWS = int(os.getenv("MAX_POLICY_ESTIMATE_IMPACT_ROWS", 1000))
DEFAULT_PROMPT_INJECTION_SIMILARITY_THRESHOLD = float(os.getenv("DEFAULT_PROMPT_INJECTION_SIMILARITY_THRESHOLD", 0.7))
LENGTH_OF_LITELLM_GENERATED_KEY = int(os.getenv("LENGTH_OF_LITELLM_GENERATED_KEY", 16))
MINIMUM_CUSTOM_KEY_LENGTH = int(os.getenv("MINIMUM_CUSTOM_KEY_LENGTH", 16))
SECRET_MANAGER_REFRESH_INTERVAL = int(os.getenv("SECRET_MANAGER_REFRESH_INTERVAL", 86400))
LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [
"default_internal_user_params",
Expand Down
4 changes: 3 additions & 1 deletion litellm/litellm_core_utils/secret_redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import re
from typing import List

from litellm.constants import MINIMUM_CUSTOM_KEY_LENGTH

_REDACTED = "REDACTED"


Expand All @@ -30,7 +32,7 @@ def _build_secret_patterns() -> "re.Pattern[str]":
# Basic auth headers
r"Basic\s+[A-Za-z0-9+/]{10,}={0,2}",
# OpenAI / Anthropic sk- prefixed keys
r"sk-[A-Za-z0-9\-_]{20,}",
rf"sk-[A-Za-z0-9\-_]{{{MINIMUM_CUSTOM_KEY_LENGTH - len('sk-')},}}",

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.

P2 _SECRET_RE baked in at import time

_SECRET_RE = _build_secret_patterns() is evaluated once when the module is first imported. MINIMUM_CUSTOM_KEY_LENGTH is correctly read from the environment at that point, so normal deployments (set env var, restart server) work correctly. However, if any future code path changes litellm.constants.MINIMUM_CUSTOM_KEY_LENGTH at runtime (e.g., via a dynamic config endpoint), the redaction pattern silently stays at the old floor. A short if __debug__ assertion or a comment noting that the constant must not change after import would guard this assumption cheaply.

# Generic api_key / api-key / apikey (handles 'key': 'value' dict repr)
r"(?:api[_-]?key)['\"]?\s*[:=]\s*['\"]?[^\s,'\"})\]{}>]{8,}",
# x-api-key / api-key header values (handles 'key': 'value' dict repr)
Expand Down
4 changes: 3 additions & 1 deletion litellm/proxy/auth/auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import litellm
from litellm import Router, provider_list
from litellm._logging import verbose_proxy_logger
from litellm.constants import STANDARD_CUSTOMER_ID_HEADERS
from litellm.constants import MINIMUM_CUSTOM_KEY_LENGTH, STANDARD_CUSTOMER_ID_HEADERS
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.litellm_core_utils.url_utils import SSRFError, validate_url
from litellm.proxy._types import *
Expand Down Expand Up @@ -1533,4 +1533,6 @@ def get_model_from_request(


def abbreviate_api_key(api_key: str) -> str:
if len(api_key) < MINIMUM_CUSTOM_KEY_LENGTH:
return "sk-..."
return f"sk-...{api_key[-4:]}"
24 changes: 19 additions & 5 deletions litellm/proxy/management_endpoints/key_management_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from litellm.constants import (
LENGTH_OF_LITELLM_GENERATED_KEY,
LITELLM_PROXY_ADMIN_NAME,
MINIMUM_CUSTOM_KEY_LENGTH,
UI_SESSION_TOKEN_TEAM_ID,
)
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
Expand Down Expand Up @@ -1022,6 +1023,14 @@ async def _common_key_generation_helper(
detail={"error": f"Invalid key format. LiteLLM Virtual Key must start with 'sk-'. Received: {_masked}"},
)

if data.key is not None and len(data.key) < MINIMUM_CUSTOM_KEY_LENGTH:
raise HTTPException(
status_code=400,
detail={
"error": f"Invalid key format. LiteLLM Virtual Key must be at least {MINIMUM_CUSTOM_KEY_LENGTH} characters long."
},
)

# check org key limits - done here to handle inheriting org id from team
if data.organization_id is not None:
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
Expand Down Expand Up @@ -1474,7 +1483,7 @@ async def generate_key_fn(
Parameters:
- duration: Optional[str] - Specify the length of time the token is valid for. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d").
- key_alias: Optional[str] - User defined key alias
- key: Optional[str] - User defined key value. If not set, a 16-digit unique sk-key is created for you.
- key: Optional[str] - User defined key value. Must start with 'sk-' and be at least 16 characters long. If not set, a 16-digit unique sk-key is created for you.
- team_id: Optional[str] - The team id of the key
- user_id: Optional[str] - The user id of the key
- agent_id: Optional[str] - The agent id associated with the key.
Expand Down Expand Up @@ -1688,7 +1697,7 @@ async def generate_service_account_key_fn(
Parameters:
- duration: Optional[str] - Specify the length of time the token is valid for. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d").
- key_alias: Optional[str] - User defined key alias
- key: Optional[str] - User defined key value. If not set, a 16-digit unique sk-key is created for you.
- key: Optional[str] - User defined key value. Must start with 'sk-' and be at least 16 characters long. If not set, a 16-digit unique sk-key is created for you.
- team_id: Optional[str] - The team id of the key
- user_id: Optional[str] - [NON-FUNCTIONAL] THIS WILL BE IGNORED. The user id of the key
- budget_id: Optional[str] - The budget id associated with the key. Created by calling `/budget/new`.
Expand Down Expand Up @@ -4356,14 +4365,19 @@ async def get_new_token(data: Optional[RegenerateKeyRequest]) -> str:
if data and data.new_key is not None:
# Reject custom key values if disabled by admin
await _check_custom_key_allowed(data.new_key)
new_token = data.new_key
if not data.new_key.startswith("sk-"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": "New key must start with 'sk-'. This is to distinguish a key hash (used by litellm for logging / internal logic) from the actual key."
},
)
if len(data.new_key) < MINIMUM_CUSTOM_KEY_LENGTH:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": f"New key must be at least {MINIMUM_CUSTOM_KEY_LENGTH} characters long."},
)
new_token = data.new_key
else:
new_token = f"sk-{secrets.token_urlsafe(LENGTH_OF_LITELLM_GENERATED_KEY)}"
return new_token
Expand Down Expand Up @@ -4470,7 +4484,7 @@ async def _execute_virtual_key_regeneration(

new_token = await get_new_token(data=data)
new_token_hash = hash_token(new_token)
new_token_key_name = f"sk-...{new_token[-4:]}"
new_token_key_name = abbreviate_api_key(api_key=new_token)
update_data = {"token": new_token_hash, "key_name": new_token_key_name}

non_default_values = {}
Expand Down Expand Up @@ -4550,7 +4564,7 @@ async def regenerate_key_fn(
- data: Optional[RegenerateKeyRequest] - Request body containing optional parameters to update
- key: Optional[str] - The key to regenerate.
- new_master_key: Optional[str] - The new master key to use, if key is the master key.
- new_key: Optional[str] - The new key to use, if key is not the master key. If both set, new_master_key will be used.
- new_key: Optional[str] - The new key to use, if key is not the master key. Must start with 'sk-' and be at least 16 characters long. If both set, new_master_key will be used.
- key_alias: Optional[str] - User-friendly key alias
- user_id: Optional[str] - User ID associated with key
- team_id: Optional[str] - Team ID associated with key
Expand Down
11 changes: 10 additions & 1 deletion tests/test_litellm/proxy/auth/test_auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,16 @@ def test_get_model_from_request_ignores_session_model_on_non_realtime_routes():


def test_abbreviate_api_key():
assert abbreviate_api_key("sk-test-1234") == "sk-...1234"
assert abbreviate_api_key("sk-test-1234-abcdefgh") == "sk-...efgh"
assert abbreviate_api_key("sk-abcdefghijklm") == "sk-...jklm"


def test_abbreviate_api_key_short_key_is_fully_masked():
"""Regression test for LIT-4355: for keys shorter than the enforced minimum,
showing the last 4 characters can reveal the entire key (sk-1234 -> sk-...1234)."""
assert abbreviate_api_key("sk-1234") == "sk-..."
assert abbreviate_api_key("sk-test-1234") == "sk-..."
assert abbreviate_api_key("") == "sk-..."


def test_get_customer_user_header_returns_none_when_no_customer_role():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ async def _insert_data_side_effect(*args, **kwargs):
generate_key_fn,
)

raw_key = "sk-short-secret"
raw_key = "sk-short-secret-a1b2"
with caplog.at_level(logging.DEBUG, logger="LiteLLM Proxy"):
await generate_key_fn(
data=GenerateKeyRequest(key=raw_key),
Expand Down Expand Up @@ -1336,10 +1336,10 @@ async def test_get_new_token_with_valid_key(monkeypatch):
)

# Test with valid new_key
data = RegenerateKeyRequest(new_key="sk-test123456789")
data = RegenerateKeyRequest(new_key="sk-test1234567890abc")
result = await get_new_token(data)

assert result == "sk-test123456789"
assert result == "sk-test1234567890abc"


@pytest.mark.asyncio
Expand Down Expand Up @@ -1370,6 +1370,110 @@ async def test_get_new_token_with_invalid_key(monkeypatch):
assert "New key must start with 'sk-'" in str(exc_info.value.detail)


@pytest.mark.asyncio
async def test_get_new_token_rejects_short_new_key(monkeypatch):
"""Regression test for LIT-4355: a short custom key like sk-99 must be rejected,
otherwise the stored key_name (sk-...{last 4 chars}) reveals the entire key."""
from unittest.mock import AsyncMock

from fastapi import HTTPException

from litellm.proxy._types import RegenerateKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import (
get_new_token,
)

monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached",
AsyncMock(return_value={}),
)

data = RegenerateKeyRequest(new_key="sk-99")

with pytest.raises(HTTPException) as exc_info:
await get_new_token(data)

assert exc_info.value.status_code == 400
assert "at least 16 characters" in str(exc_info.value.detail)


@pytest.mark.asyncio
@pytest.mark.parametrize("short_key", ["sk-1234", "sk-abcdefghijkl"])
async def test_generate_key_fn_rejects_short_custom_key(monkeypatch, short_key):
"""Regression test for LIT-4355: /key/generate must reject custom keys shorter
than the minimum length (including the 15-char boundary); sk-1234 used to be
accepted and fully exposed via key_name."""
mock_prisma_client = AsyncMock()
mock_prisma_client.db = MagicMock()
mock_prisma_client.db.litellm_verificationtoken = MagicMock()
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None)
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])

from litellm.proxy._types import GenerateKeyRequest, LitellmUserRoles, ProxyException
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth
from litellm.proxy.management_endpoints.key_management_endpoints import (
generate_key_fn,
)

monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached",
AsyncMock(return_value={}),
)

assert len(short_key) < 16

with pytest.raises(ProxyException) as exc_info:
await generate_key_fn(
data=GenerateKeyRequest(key=short_key),
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1234", user_id="1234"
),
)

assert exc_info.value.code == "400"
assert "at least 16 characters" in str(exc_info.value.message)


@pytest.mark.asyncio
async def test_generate_key_fn_accepts_custom_key_at_minimum_length(monkeypatch):
"""Custom keys at exactly the minimum length (16 chars) are still accepted."""
mock_prisma_client = AsyncMock()
mock_insert_data = AsyncMock(
return_value=MagicMock(token="hashed_token_123", litellm_budget_table=None, object_permission=None)
)
mock_prisma_client.insert_data = mock_insert_data
mock_prisma_client.db = MagicMock()
mock_prisma_client.db.litellm_verificationtoken = MagicMock()
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None)
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
mock_prisma_client.db.litellm_verificationtoken.count = AsyncMock(return_value=0)

from litellm.proxy._types import GenerateKeyRequest, LitellmUserRoles
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth
from litellm.proxy.management_endpoints.key_management_endpoints import (
generate_key_fn,
)

monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached",
AsyncMock(return_value={}),
)

custom_key = "sk-abcdefghijklm"
assert len(custom_key) == 16

response = await generate_key_fn(
data=GenerateKeyRequest(key=custom_key),
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1234", user_id="1234"
),
)

assert response.key == custom_key


@pytest.mark.asyncio
async def test_check_custom_key_allowed_when_disabled(monkeypatch):
"""_check_custom_key_allowed raises 403 when disable_custom_api_keys is true."""
Expand Down
10 changes: 10 additions & 0 deletions tests/test_litellm/test_secret_redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ def test_redact_string_catches_secret_patterns():
assert redact_string(normal) == normal


def test_redact_string_catches_minimum_length_virtual_key():
"""Regression test for LIT-4355: keys at the enforced 16-char minimum
(MINIMUM_CUSTOM_KEY_LENGTH) must be treated as key-shaped by the scrubber."""
minimum_length_key = "sk-abcdefghijklm"
assert len(minimum_length_key) == 16
result = redact_string("msg: " + minimum_length_key)
assert minimum_length_key not in result
assert "REDACTED" in result


def test_filter_redacts_secrets_in_logger_output():
def log_messages():
verbose_logger.debug("Key: " + SECRET)
Expand Down
8 changes: 4 additions & 4 deletions ui/litellm-dashboard/src/lib/http/schema.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading