From 699c9465c91a2301e165eb9ea0afb41b9b0d7869 Mon Sep 17 00:00:00 2001 From: Mubashir Osmani Date: Tue, 20 Oct 2026 20:41:38 +0000 Subject: [PATCH 1/2] fix: reject master key regeneration requests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../key_management_endpoints.py | 50 +++++------------ .../management_endpoints/key_requests.py | 1 - .../management/test_key_regenerate.py | 55 +++++++++++++++++++ .../test_key_management_endpoints.py | 31 +++++------ ui/litellm-dashboard/src/lib/http/schema.d.ts | 8 +-- 5 files changed, 86 insertions(+), 59 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 824b310a1dd..4b2a72c975d 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -6274,8 +6274,7 @@ async def regenerate_key_fn( - key: str (path parameter) - The key to regenerate - 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. Must start with 'sk-' and be at least 16 characters long. If both set, new_master_key will be used. + - new_key: Optional[str] - The new key to use. Must start with 'sk-' and be at least 16 characters long. - 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 @@ -6355,21 +6354,21 @@ async def regenerate_key_fn( allow_safe_presets=True, ) - # Premium-gate bypass for master-key rotation must verify the - # caller actually holds the master key, not just that the request - # body has a ``new_master_key`` field. A presence-only check let - # any non-premium caller skip the enterprise gate by sending any - # value in that field. regenerate_target_key: Final = data.key if data and data.key else key - is_master_key_regeneration: Final = ( - data is not None - and data.new_master_key is not None - and is_master_key(api_key=regenerate_target_key, _master_key=master_key) - ) + if master_key is not None and is_master_key(api_key=regenerate_target_key, _master_key=master_key): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": ( + "Rotating the master key through /key/regenerate is no longer supported. " + "Set the new value in LITELLM_MASTER_KEY or general_settings.master_key and restart every " + "proxy instance. If LITELLM_SALT_KEY is not set, first set it to your current master key. " + "See https://docs.litellm.ai/docs/proxy/master_key_rotations" + ) + }, + ) - if ( - premium_user is not True and not is_master_key_regeneration - ): # allow master key regeneration for non-premium users + if premium_user is not True: raise ValueError( f"Regenerating Virtual Keys is an Enterprise feature, {CommonProxyErrors.not_premium_user.value}" ) @@ -6392,27 +6391,6 @@ async def regenerate_key_fn( detail={"error": "DB not connected. prisma_client is None"}, ) - _is_master_key_valid: Final = is_master_key(api_key=key, _master_key=master_key) - - if master_key is not None and data and _is_master_key_valid: - if data.new_master_key is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={"error": "New master key is required."}, - ) - await rotate_master_key( - prisma_client=prisma_client, - user_api_key_dict=user_api_key_dict, - current_master_key=master_key, - new_master_key=data.new_master_key, - ) - return GenerateKeyResponse( - key=data.new_master_key, - token=data.new_master_key, - key_name=data.new_master_key, - expires=None, - ) - if "sk" not in key: hashed_api_key = key else: diff --git a/litellm/types/proxy/management_endpoints/key_requests.py b/litellm/types/proxy/management_endpoints/key_requests.py index baa02b68d98..72305c6c6d3 100644 --- a/litellm/types/proxy/management_endpoints/key_requests.py +++ b/litellm/types/proxy/management_endpoints/key_requests.py @@ -134,5 +134,4 @@ class RegenerateKeyRequest(GenerateKeyRequest): duration: str | None = None spend: float | None = None metadata: dict[Any, Any] | None = None - new_master_key: str | None = None grace_period: str | None = None # Duration to keep old key valid (e.g. "24h", "2d"); None = immediate revoke diff --git a/tests/integration/management/test_key_regenerate.py b/tests/integration/management/test_key_regenerate.py index 3cd81520047..532c25b95a0 100644 --- a/tests/integration/management/test_key_regenerate.py +++ b/tests/integration/management/test_key_regenerate.py @@ -1,7 +1,12 @@ +import json +from typing import Final + import pytest from tests.integration._support.actors import TEAM_ALPHA, TEAM_BETA, Actor from tests.integration._support.actors import create_scratch_key, owned_settings_client +from tests.integration._support.client import Gateway, object_value +from tests.integration._support.wire import Reply, Request, wire_server pytestmark = pytest.mark.asyncio(loop_scope="session") @@ -40,6 +45,56 @@ async def _info(gateway_client, cleartext: str): ) +async def test_master_key_regeneration_is_rejected_without_rotating_model(gateway: Gateway): + master_key_regeneration_error: Final = ( + "Rotating the master key through /key/regenerate is no longer supported. " + "Set the new value in LITELLM_MASTER_KEY or general_settings.master_key and restart every proxy instance. " + "If LITELLM_SALT_KEY is not set, first set it to your current master key. " + "See https://docs.litellm.ai/docs/proxy/master_key_rotations" + ) + + def upstream(_: Request) -> Reply: + return Reply( + body=json.dumps( + { + "id": "master-key-regeneration-response", + "object": "chat.completion", + "created": 1, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "available"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + ).encode() + ) + + with gateway.scenario() as scenario, wire_server(upstream) as provider: + model: Final = scenario.model(api_base=f"{provider.url}/v1") + response: Final = gateway.request( + "POST", + "/key/regenerate", + {"key": gateway.key, "new_master_key": "sk-new-master-key"}, + ) + assert response.status_code == 400, response.text + assert response.json()["error"]["message"] == str({"error": master_key_regeneration_error}) + + model_info: Final = gateway.request("GET", "/model/info") + assert model_info.status_code == 200, model_info.text + listed: Final = model_info.json()["data"] + assert isinstance(listed, list) + assert any(object_value(entry)["model_name"] == model for entry in listed) + served: Final = gateway.chat(model, text="master key regeneration") + assert object_value(served["choices"][0])["message"] == { + "role": "assistant", + "content": "available", + } + + @pytest.mark.parametrize( "actor,target_shape,expected_status", [(a, t, s) for (_id, a, t, s) in _SCENARIOS], diff --git a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py index d6ee9b1d29e..ff47869bc47 100644 --- a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py @@ -15931,16 +15931,13 @@ async def test_regenerate_user_id_rebind_guard( @pytest.mark.asyncio -async def test_regenerate_premium_gate_requires_actual_master_key(): - # ``regenerate_key_fn``'s decorator wraps the underlying ValueError - # into a ProxyException with empty ``message``. The exception type - # alone confirms the premium gate fired. +async def test_regenerate_premium_gate_rejects_non_master_key(): from litellm.proxy._types import RegenerateKeyRequest from litellm.proxy.management_endpoints.key_management_endpoints import ( regenerate_key_fn, ) - data = RegenerateKeyRequest(key="sk-not-master", new_master_key="anything") + data = RegenerateKeyRequest(key="sk-not-master") with ( patch("litellm.proxy.proxy_server.premium_user", False), @@ -15956,25 +15953,22 @@ async def test_regenerate_premium_gate_requires_actual_master_key(): @pytest.mark.asyncio -async def test_regenerate_premium_gate_allows_actual_master_key_holder(): +async def test_regenerate_rejects_master_key(): from litellm.proxy._types import RegenerateKeyRequest from litellm.proxy.management_endpoints.key_management_endpoints import ( regenerate_key_fn, ) master = "sk-the-real-master-key" - data = RegenerateKeyRequest(key=master, new_master_key="sk-new-master") + data = RegenerateKeyRequest(key=master) with ( patch("litellm.proxy.proxy_server.premium_user", False), patch("litellm.proxy.proxy_server.master_key", master), patch("litellm.proxy.proxy_server.prisma_client", AsyncMock()), - patch( - "litellm.proxy.management_endpoints.key_management_endpoints.rotate_master_key", - new_callable=AsyncMock, - ), + pytest.raises(ProxyException) as exc_info, ): - result = await regenerate_key_fn( + await regenerate_key_fn( key=master, data=data, user_api_key_dict=UserAPIKeyAuth( @@ -15984,7 +15978,13 @@ async def test_regenerate_premium_gate_allows_actual_master_key_holder(): ), ) - assert result.token == "sk-new-master" + assert exc_info.value.code == "400" + assert exc_info.value.message == ( + "{'error': 'Rotating the master key through /key/regenerate is no longer supported. " + "Set the new value in LITELLM_MASTER_KEY or general_settings.master_key and restart every proxy instance. " + "If LITELLM_SALT_KEY is not set, first set it to your current master key. " + "See https://docs.litellm.ai/docs/proxy/master_key_rotations'}" + ) @pytest.mark.asyncio @@ -23214,6 +23214,8 @@ def test_key_info_unexpected_database_exception_sanitized( proxy_server.app.dependency_overrides.pop(user_api_key_auth, None) + + def test_key_info_missing_key_preserves_404( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -23304,6 +23306,3 @@ def test_key_info_database_not_connected_sanitized_500( assert "Database not connected" in caplog.text finally: proxy_server.app.dependency_overrides.pop(user_api_key_auth, None) - - - diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index e432c947116..49b8acae0a7 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -8654,8 +8654,7 @@ export interface paths { * - key: str (path parameter) - The key to regenerate * - 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. Must start with 'sk-' and be at least 16 characters long. If both set, new_master_key will be used. + * - new_key: Optional[str] - The new key to use. Must start with 'sk-' and be at least 16 characters long. * - 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 @@ -8953,8 +8952,7 @@ export interface paths { * - key: str (path parameter) - The key to regenerate * - 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. Must start with 'sk-' and be at least 16 characters long. If both set, new_master_key will be used. + * - new_key: Optional[str] - The new key to use. Must start with 'sk-' and be at least 16 characters long. * - 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 @@ -41995,8 +41993,6 @@ export interface components { models: unknown[] | null; /** New Key */ new_key?: string | null; - /** New Master Key */ - new_master_key?: string | null; object_permission?: components["schemas"]["LiteLLM_ObjectPermissionBase"] | null; /** Organization Id */ organization_id?: string | null; From 47ea8f7cb497d2224b940dab67c425d755c2e8fe Mon Sep 17 00:00:00 2001 From: Mubashir Osmani Date: Tue, 20 Oct 2026 20:42:33 +0000 Subject: [PATCH 2/2] test: drop blank-line churn in key management tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_endpoints/test_key_management_endpoints.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py index ff47869bc47..0ee6cc3a98c 100644 --- a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py @@ -23214,8 +23214,6 @@ def test_key_info_unexpected_database_exception_sanitized( proxy_server.app.dependency_overrides.pop(user_api_key_auth, None) - - def test_key_info_missing_key_preserves_404( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -23306,3 +23304,6 @@ def test_key_info_database_not_connected_sanitized_500( assert "Database not connected" in caplog.text finally: proxy_server.app.dependency_overrides.pop(user_api_key_auth, None) + + +