diff --git a/litellm/litellm_core_utils/sensitive_data_masker.py b/litellm/litellm_core_utils/sensitive_data_masker.py index 8b88ef94821..d7803455b4a 100644 --- a/litellm/litellm_core_utils/sensitive_data_masker.py +++ b/litellm/litellm_core_utils/sensitive_data_masker.py @@ -21,6 +21,8 @@ class SensitiveDataMasker: "auth", "authorization", "credential", + # Plural form: Vertex uses ``vertex_credentials``; segment-exact + # matching otherwise misses it because "credential" != "credentials". "credentials", "access", "private", diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index a3200d506d6..acfefff3028 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -40,12 +40,7 @@ from litellm.vector_stores.vector_store_registry import VectorStoreRegistry router = APIRouter() -# Inherit the default sensitive-key heuristics and add the plural -# ``credentials`` so segment-exact matching catches Vertex's -# ``vertex_credentials`` (the singular ``credential`` pattern misses it). -_LITELLM_PARAMS_MASKER = SensitiveDataMasker( - sensitive_patterns={*SensitiveDataMasker().sensitive_patterns, "credentials"}, -) +_LITELLM_PARAMS_MASKER = SensitiveDataMasker() def _redact_sensitive_litellm_params( @@ -812,6 +807,25 @@ async def update_vector_store( update_data = data.model_dump(exclude_unset=True) vector_store_id = update_data.pop("vector_store_id") + # Per-store access control: anyone authenticated who passes the + # premium-feature gate could otherwise update *any* vector store — + # including stores belonging to other teams. Mirror the check + # ``/vector_store/info`` already performs. + existing = await prisma_client.db.litellm_managedvectorstorestable.find_unique( + where={"vector_store_id": vector_store_id} + ) + if existing is None: + raise HTTPException( + status_code=404, + detail=f"Vector store with ID {vector_store_id} not found", + ) + existing_typed = LiteLLM_ManagedVectorStore(**existing.model_dump()) + if not await _check_vector_store_access(existing_typed, user_api_key_dict): + raise HTTPException( + status_code=403, + detail="Access denied: You do not have permission to update this vector store", + ) + # Handle metadata serialization if update_data.get("vector_store_metadata") is not None: update_data["vector_store_metadata"] = safe_dumps( @@ -859,11 +873,24 @@ async def update_vector_store( f"Updated vector store {vector_store_id} in both database and in-memory registry" ) + # The DB row is returned in full, so the response would otherwise + # echo the persisted ``litellm_params`` (including provider + # credentials) back to the caller — even when the caller only + # changed unrelated fields like ``vector_store_description``. + response_vs = LiteLLM_ManagedVectorStore(**updated_vs) + response_vs["litellm_params"] = _redact_sensitive_litellm_params( + updated_vs.get("litellm_params") + ) return { "status": "success", "message": f"Vector store {vector_store_id} updated successfully", - "vector_store": updated_vs, + "vector_store": response_vs, } + except HTTPException: + # Preserve 403/404 responses from the access-control / not-found + # checks above; the catch-all below would otherwise rewrite them + # as 500 with the original status code embedded in the detail. + raise except Exception as e: verbose_proxy_logger.exception(f"Error updating vector store: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py index 699e58442de..92027e0a6f2 100644 --- a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py +++ b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py @@ -1950,3 +1950,134 @@ class TestRedactSensitiveLitellmParams: snapshot = dict(original) _redact_sensitive_litellm_params(original) assert original == snapshot, "input dict must not be mutated" + + +class TestUpdateVectorStoreAccessControlAndRedaction: + """ + ``/vector_store/update`` previously skipped per-store access control + (only the premium-feature gate ran), letting any authenticated + premium principal mutate *any* vector store. It also returned the + full DB row including ``litellm_params``, leaking provider + credentials to the caller. Both are fixed at the endpoint level. + """ + + @pytest.mark.asyncio + async def test_update_denied_when_caller_cannot_access_store(self): + from unittest.mock import AsyncMock, MagicMock, patch + + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.vector_store_endpoints.management_endpoints import ( + update_vector_store, + ) + from litellm.types.vector_stores import VectorStoreUpdateRequest + + existing_row = MagicMock() + existing_row.model_dump = MagicMock( + return_value={ + "vector_store_id": "vs_other_team", + "team_id": "team-A", + "litellm_params": {"api_key": "sk-team-A-secret"}, + } + ) + + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock( + return_value=existing_row + ) + + with ( + patch( + "litellm.proxy.vector_store_endpoints.management_endpoints.check_feature_access_for_user", + new_callable=AsyncMock, + ), + patch( + "litellm.proxy.vector_store_endpoints.management_endpoints._check_vector_store_access", + new_callable=AsyncMock, + return_value=False, + ), + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), + ): + with pytest.raises(HTTPException) as exc_info: + await update_vector_store( + data=VectorStoreUpdateRequest( + vector_store_id="vs_other_team", + vector_store_description="hijacked", + ), + user_api_key_dict=UserAPIKeyAuth( + user_id="attacker", team_id="team-B" + ), + ) + assert exc_info.value.status_code == 403 + # The attacker must NOT see the existing credential in the + # error message either. + assert "sk-team-A-secret" not in str(exc_info.value.detail) + # And the DB update must not have been called. + mock_prisma_client.db.litellm_managedvectorstorestable.update.assert_not_called() + + @pytest.mark.asyncio + async def test_update_response_redacts_litellm_params(self): + from unittest.mock import AsyncMock, MagicMock, patch + + from litellm.constants import REDACTED_BY_LITELM_STRING + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.vector_store_endpoints.management_endpoints import ( + update_vector_store, + ) + from litellm.types.vector_stores import VectorStoreUpdateRequest + + existing_row = MagicMock() + existing_row.model_dump = MagicMock( + return_value={ + "vector_store_id": "vs_owned", + "team_id": "team-A", + "litellm_params": { + "api_key": "sk-real-openai-key-123", + "api_base": "https://api.openai.com/v1", + }, + } + ) + updated_row = MagicMock() + updated_row.model_dump = MagicMock( + return_value={ + "vector_store_id": "vs_owned", + "team_id": "team-A", + "vector_store_description": "new desc", + "litellm_params": { + "api_key": "sk-real-openai-key-123", + "api_base": "https://api.openai.com/v1", + }, + } + ) + + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock( + return_value=existing_row + ) + mock_prisma_client.db.litellm_managedvectorstorestable.update = AsyncMock( + return_value=updated_row + ) + + with ( + patch( + "litellm.proxy.vector_store_endpoints.management_endpoints.check_feature_access_for_user", + new_callable=AsyncMock, + ), + patch( + "litellm.proxy.vector_store_endpoints.management_endpoints._check_vector_store_access", + new_callable=AsyncMock, + return_value=True, + ), + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), + patch("litellm.vector_store_registry", None), + ): + response = await update_vector_store( + data=VectorStoreUpdateRequest( + vector_store_id="vs_owned", + vector_store_description="new desc", + ), + user_api_key_dict=UserAPIKeyAuth(user_id="owner", team_id="team-A"), + ) + + params = response["vector_store"]["litellm_params"] + assert params["api_key"] == REDACTED_BY_LITELM_STRING + assert params["api_base"] == "https://api.openai.com/v1"