fix(key management): enforce minimum custom key length and mask short keys in key_name (#33462)

* fix(key management): enforce minimum custom key length and mask short keys in key_name

* fix(key management): validate new_key before assignment and sync generated schema docstrings

* fix(key management): lower minimum custom key length default from 20 to 16
This commit is contained in:
yucheng-berri 2026-07-15 18:27:38 -07:00 committed by GitHub
parent 614dd8756e
commit 3cea243116
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 157 additions and 15 deletions

View file

@ -1496,6 +1496,7 @@ MAX_TEAM_LIST_LIMIT = int(os.getenv("MAX_TEAM_LIST_LIMIT", 20))
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",

View file

@ -9,6 +9,8 @@ secrets from strings without depending on the logging-configuration module.
import re
from typing import List
from litellm.constants import MINIMUM_CUSTOM_KEY_LENGTH
_REDACTED = "REDACTED"
@ -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-')},}}",
# 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)

View file

@ -10,7 +10,7 @@ from fastapi import HTTPException, Request, status
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 *
@ -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:]}"

View file

@ -32,6 +32,7 @@ from litellm._uuid import uuid
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
@ -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
@ -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.
@ -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`.
@ -4356,7 +4365,6 @@ 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,
@ -4364,6 +4372,12 @@ async def get_new_token(data: Optional[RegenerateKeyRequest]) -> str:
"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
@ -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 = {}
@ -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

View file

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

View file

@ -563,7 +563,7 @@ async def test_generate_key_debug_log_never_contains_raw_token(monkeypatch, capl
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),
@ -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
@ -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."""

View file

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

View file

@ -6544,7 +6544,7 @@ export interface paths {
* 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.
@ -6765,7 +6765,7 @@ export interface paths {
* - 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
@ -6834,7 +6834,7 @@ export interface paths {
* 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`.
@ -7024,7 +7024,7 @@ export interface paths {
* - 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