mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
614dd8756e
commit
3cea243116
8 changed files with 157 additions and 15 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:]}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue