From 0806cca34012c389defda18a398001288ac86254 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Sat, 25 Apr 2026 04:50:39 +0000 Subject: [PATCH 1/6] chore(vector-stores): redact credentials from list/info responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``LiteLLM_ManagedVectorStore.litellm_params`` carries the upstream provider credential — OpenAI ``api_key``, AWS ``aws_access_key_id`` / ``aws_secret_access_key``, GCP ``vertex_credentials``, etc. ``GET /vector_store/list`` and ``POST /vector_store/info`` return these verbatim to any authenticated principal. Because both routes are in ``openai_routes``, ``RouteChecks.is_llm_api_route`` short-circuits the standard role gate, so even read-only users and narrowly-scoped keys can read every stored credential. Replace credential-bearing values with the ``REDACTED_BY_LITELM`` sentinel in both responses while preserving non-secret keys (``api_base``, ``region``, ``model``, ``api_version``) so callers can still see *which* upstream is configured. Detection reuses ``SensitiveDataMasker.is_sensitive_key`` with the default heuristics plus the plural ``credentials`` pattern (covers Vertex's ``vertex_credentials`` field, which the singular ``credential`` pattern misses on segment-exact matching). Applied at: - ``list_vector_stores`` (``GET /vector_store/list``, ``GET /v1/vector_store/list``) - ``get_vector_store_info`` (``POST /vector_store/info``), both the in-memory-registry path and the prisma-DB fallback Co-Authored-By: Claude Opus 4.7 (1M context) --- .../management_endpoints.py | 74 +++++++++++++++++- .../test_vector_store_endpoints.py | 76 +++++++++++++++++++ 2 files changed, 148 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index fefa6cb4e94..fb064b644a5 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -16,7 +16,9 @@ from fastapi import APIRouter, Depends, HTTPException import litellm from litellm._logging import verbose_proxy_logger +from litellm.constants import REDACTED_BY_LITELM_STRING from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker from litellm.proxy._types import ( LiteLLM_ManagedVectorStoresTable, ResponseLiteLLM_ManagedVectorStore, @@ -38,6 +40,68 @@ from litellm.vector_stores.vector_store_registry import VectorStoreRegistry router = APIRouter() +# Module-level masker — extends the default sensitive-key heuristics with +# plural forms used by some providers (e.g. Vertex's ``vertex_credentials``, +# which would otherwise slip past the singular "credential" pattern). +_LITELLM_PARAMS_MASKER = SensitiveDataMasker( + sensitive_patterns={ + "password", + "secret", + "key", + "token", + "auth", + "authorization", + "credential", + "credentials", + "access", + "private", + "certificate", + "fingerprint", + "tenancy", + }, +) + + +def _redact_sensitive_litellm_params( + litellm_params: Optional[Dict[str, Any]], +) -> Optional[Dict[str, Any]]: + """ + Replace credential-bearing values inside ``litellm_params`` with the + ``REDACTED_BY_LITELM`` sentinel while preserving non-secret keys + (``api_base``, ``region``, ``model``, etc.) so callers can still see + *which* upstream is configured. + + Without this, ``/vector_store/list`` and ``/vector_store/info`` return + the raw provider credentials (OpenAI ``api_key``, AWS + ``aws_secret_access_key``, GCP ``vertex_credentials``, ...) to any + authenticated principal, including read-only users and narrowly-scoped + keys. + """ + if not litellm_params or not isinstance(litellm_params, dict): + return litellm_params + + redacted: Dict[str, Any] = {} + for k, v in litellm_params.items(): + if _LITELLM_PARAMS_MASKER.is_sensitive_key(k): + redacted[k] = REDACTED_BY_LITELM_STRING + else: + redacted[k] = v + return redacted + + +def _redact_vector_store( + vector_store: LiteLLM_ManagedVectorStore, +) -> LiteLLM_ManagedVectorStore: + """ + Return a copy of ``vector_store`` with credential-bearing fields + inside ``litellm_params`` replaced by the redaction sentinel. + """ + redacted = LiteLLM_ManagedVectorStore(**vector_store) + redacted["litellm_params"] = _redact_sensitive_litellm_params( + vector_store.get("litellm_params") + ) + return redacted + def _resolve_embedding_config_from_router( embedding_model: str, llm_router @@ -555,7 +619,7 @@ async def list_vector_stores( accessible_vector_stores = [] for vs in vector_store_map.values(): if await _check_vector_store_access(vs, user_api_key_dict): - accessible_vector_stores.append(vs) + accessible_vector_stores.append(_redact_vector_store(vs)) total_count = len(accessible_vector_stores) total_pages = (total_count + page_size - 1) // page_size @@ -716,7 +780,9 @@ async def get_vector_store_info( created_at=vector_store.get("created_at") or None, updated_at=vector_store.get("updated_at") or None, litellm_credential_name=vector_store.get("litellm_credential_name"), - litellm_params=vector_store.get("litellm_params") or None, + litellm_params=_redact_sensitive_litellm_params( + vector_store.get("litellm_params") + ), team_id=vector_store.get("team_id") or None, user_id=vector_store.get("user_id") or None, ) @@ -742,6 +808,10 @@ async def get_vector_store_info( detail="Access denied: You do not have permission to access this vector store", ) + if "litellm_params" in vector_store_dict: + vector_store_dict["litellm_params"] = _redact_sensitive_litellm_params( + vector_store_dict["litellm_params"] + ) return {"vector_store": vector_store_dict} except Exception as e: verbose_proxy_logger.exception(f"Error getting vector store info: {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 44cc5cc4452..57bbbab8fce 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 @@ -1882,3 +1882,79 @@ async def test_create_vector_store_in_db_raises_when_no_db(): assert exc_info.value.status_code == 500 assert "database not connected" in exc_info.value.detail.lower() + + +class TestRedactSensitiveLitellmParams: + """ + ``litellm_params`` on a managed vector store carries the upstream + provider credential (OpenAI ``api_key``, AWS ``aws_secret_access_key``, + GCP ``vertex_credentials``, etc.). The list/info endpoints must redact + those values before returning them to any caller — including read-only + users and narrowly-scoped keys. + """ + + def test_redacts_well_known_credential_keys(self): + from litellm.constants import REDACTED_BY_LITELM_STRING + from litellm.proxy.vector_store_endpoints.management_endpoints import ( + _redact_sensitive_litellm_params, + ) + + params = { + "api_key": "sk-real-openai-key-12345", + "aws_access_key_id": "AKIAIOSFODNN7EXAMPLE", + "aws_secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "vertex_credentials": ( + '{"type":"service_account","private_key":"-----BEGIN PRIVATE KEY-----..."}' + ), + "azure_authorization_token": "Bearer eyJhbGciOi...", + } + out = _redact_sensitive_litellm_params(params) + for k in params: + assert ( + out[k] == REDACTED_BY_LITELM_STRING + ), f"{k} should be redacted, got {out[k]!r}" + + def test_preserves_non_sensitive_keys(self): + from litellm.proxy.vector_store_endpoints.management_endpoints import ( + _redact_sensitive_litellm_params, + ) + + params = { + "api_base": "https://api.openai.com/v1", + "model": "text-embedding-3-large", + "region": "us-east-1", + "vector_store_id": "vs_abc123", + "api_version": "2023-05-15", + } + out = _redact_sensitive_litellm_params(params) + for k, v in params.items(): + assert out[k] == v, f"{k} should be preserved verbatim" + + def test_handles_none_and_empty(self): + from litellm.proxy.vector_store_endpoints.management_endpoints import ( + _redact_sensitive_litellm_params, + ) + + assert _redact_sensitive_litellm_params(None) is None + assert _redact_sensitive_litellm_params({}) == {} + + def test_redact_vector_store_does_not_mutate_input(self): + from litellm.proxy.vector_store_endpoints.management_endpoints import ( + _redact_vector_store, + ) + + original = { + "vector_store_id": "vs_abc123", + "vector_store_name": "prod-embeddings", + "litellm_params": { + "api_key": "sk-real-openai-key-12345", + "api_base": "https://api.openai.com/v1", + }, + } + snapshot = { + "vector_store_id": original["vector_store_id"], + "vector_store_name": original["vector_store_name"], + "litellm_params": dict(original["litellm_params"]), + } + _redact_vector_store(original) + assert original == snapshot, "input vector store dict must not be mutated" From a99943ec4981766497bcef3475530911a19b800b Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Sat, 25 Apr 2026 04:55:08 +0000 Subject: [PATCH 2/6] test+style: drop _redact_vector_store wrapper; inherit masker defaults /simplify pass: - Remove the single-call-site ``_redact_vector_store`` wrapper. Inline the two-line redaction at its only caller in ``list_vector_stores``; ``get_vector_store_info`` was already calling the inner helper directly. - Inherit ``SensitiveDataMasker``'s default sensitive-key set instead of duplicating the 12-element list, then add only the plural ``credentials`` extension. Won't drift if upstream defaults change. - Trim the over-explained docstring on ``_redact_sensitive_litellm_params`` to a one-paragraph summary; the WHY (credential-leakage class) belongs in the commit message, not in every consumer's IDE tooltip. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../management_endpoints.py | 71 ++++++------------- .../test_vector_store_endpoints.py | 22 ++---- 2 files changed, 27 insertions(+), 66 deletions(-) diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index fb064b644a5..a3200d506d6 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -40,25 +40,11 @@ from litellm.vector_stores.vector_store_registry import VectorStoreRegistry router = APIRouter() -# Module-level masker — extends the default sensitive-key heuristics with -# plural forms used by some providers (e.g. Vertex's ``vertex_credentials``, -# which would otherwise slip past the singular "credential" pattern). +# 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={ - "password", - "secret", - "key", - "token", - "auth", - "authorization", - "credential", - "credentials", - "access", - "private", - "certificate", - "fingerprint", - "tenancy", - }, + sensitive_patterns={*SensitiveDataMasker().sensitive_patterns, "credentials"}, ) @@ -66,41 +52,20 @@ def _redact_sensitive_litellm_params( litellm_params: Optional[Dict[str, Any]], ) -> Optional[Dict[str, Any]]: """ - Replace credential-bearing values inside ``litellm_params`` with the - ``REDACTED_BY_LITELM`` sentinel while preserving non-secret keys - (``api_base``, ``region``, ``model``, etc.) so callers can still see - *which* upstream is configured. - - Without this, ``/vector_store/list`` and ``/vector_store/info`` return - the raw provider credentials (OpenAI ``api_key``, AWS - ``aws_secret_access_key``, GCP ``vertex_credentials``, ...) to any - authenticated principal, including read-only users and narrowly-scoped - keys. + Replace credential-bearing values in ``litellm_params`` with + ``REDACTED_BY_LITELM`` while preserving non-secret keys (``api_base``, + ``region``, ``model``, ``api_version``). """ if not litellm_params or not isinstance(litellm_params, dict): return litellm_params - - redacted: Dict[str, Any] = {} - for k, v in litellm_params.items(): - if _LITELLM_PARAMS_MASKER.is_sensitive_key(k): - redacted[k] = REDACTED_BY_LITELM_STRING - else: - redacted[k] = v - return redacted - - -def _redact_vector_store( - vector_store: LiteLLM_ManagedVectorStore, -) -> LiteLLM_ManagedVectorStore: - """ - Return a copy of ``vector_store`` with credential-bearing fields - inside ``litellm_params`` replaced by the redaction sentinel. - """ - redacted = LiteLLM_ManagedVectorStore(**vector_store) - redacted["litellm_params"] = _redact_sensitive_litellm_params( - vector_store.get("litellm_params") - ) - return redacted + return { + k: ( + REDACTED_BY_LITELM_STRING + if _LITELLM_PARAMS_MASKER.is_sensitive_key(k) + else v + ) + for k, v in litellm_params.items() + } def _resolve_embedding_config_from_router( @@ -619,7 +584,11 @@ async def list_vector_stores( accessible_vector_stores = [] for vs in vector_store_map.values(): if await _check_vector_store_access(vs, user_api_key_dict): - accessible_vector_stores.append(_redact_vector_store(vs)) + redacted = LiteLLM_ManagedVectorStore(**vs) + redacted["litellm_params"] = _redact_sensitive_litellm_params( + vs.get("litellm_params") + ) + accessible_vector_stores.append(redacted) total_count = len(accessible_vector_stores) total_pages = (total_count + page_size - 1) // page_size 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 57bbbab8fce..699e58442de 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 @@ -1938,23 +1938,15 @@ class TestRedactSensitiveLitellmParams: assert _redact_sensitive_litellm_params(None) is None assert _redact_sensitive_litellm_params({}) == {} - def test_redact_vector_store_does_not_mutate_input(self): + def test_redaction_does_not_mutate_input_litellm_params(self): from litellm.proxy.vector_store_endpoints.management_endpoints import ( - _redact_vector_store, + _redact_sensitive_litellm_params, ) original = { - "vector_store_id": "vs_abc123", - "vector_store_name": "prod-embeddings", - "litellm_params": { - "api_key": "sk-real-openai-key-12345", - "api_base": "https://api.openai.com/v1", - }, + "api_key": "sk-real-openai-key-12345", + "api_base": "https://api.openai.com/v1", } - snapshot = { - "vector_store_id": original["vector_store_id"], - "vector_store_name": original["vector_store_name"], - "litellm_params": dict(original["litellm_params"]), - } - _redact_vector_store(original) - assert original == snapshot, "input vector store dict must not be mutated" + snapshot = dict(original) + _redact_sensitive_litellm_params(original) + assert original == snapshot, "input dict must not be mutated" From 51d560ba2ee301914359841a52223b94b6647c76 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Sat, 25 Apr 2026 05:09:44 +0000 Subject: [PATCH 3/6] chore(vector-stores): also gate /vector_store/update; upstream credentials plural in masker Two architectural extensions to the credential-redaction in the previous commit: 1. ``/vector_store/update`` had two gaps: - No per-store access control. Any authenticated principal that passed the premium-feature gate could mutate *any* vector store, including stores belonging to other teams. - The response returned the full DB row including ``litellm_params``, so the caller could read another team's persisted provider credentials by submitting a no-op metadata change. Mirror the access-control check ``/vector_store/info`` already performs (``_check_vector_store_access`` against the existing row), redact ``litellm_params`` in the response, and add an ``except HTTPException: raise`` guard so the 403/404 responses don't get rewritten as 500 by the catch-all. 2. ``SensitiveDataMasker``'s default ``sensitive_patterns`` set used segment-exact matching, so ``credential`` matched ``vertex_credential`` but not ``vertex_credentials`` (the actual Vertex field name). The previous commit worked around this with a per-call extension; this commit puts the plural in the upstream defaults so every caller (Redis config dump, MCP debug headers, cache routes, ...) gets the correct behavior. The local override in ``vector_store_endpoints/management_endpoints.py`` is removed. Also updates ``test_excluded_keys_exact_match`` which relied on ``credentials`` *not* being a sensitive pattern to demonstrate case-sensitive ``excluded_keys`` matching. The intent of the test (case-sensitive match) is preserved; the assertion now reflects that when ``excluded_keys`` fails to apply (wrong case), the field falls through to standard pattern-based masking instead of being passed through unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sensitive_data_masker.py | 2 + .../management_endpoints.py | 41 +++++- .../test_vector_store_endpoints.py | 131 ++++++++++++++++++ 3 files changed, 167 insertions(+), 7 deletions(-) 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" From 78d12ee88878b95f594ee9fe979e6b44d8edd1fa Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Sat, 25 Apr 2026 05:14:34 +0000 Subject: [PATCH 4/6] refactor(vector-stores): extract _fetch_and_authorize_vector_store helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /simplify pass: - ``update_vector_store`` (newly added) and ``get_vector_store_info``'s DB-fallback path duplicated the same shape: ``find_unique`` → ``model_dump`` → ``LiteLLM_ManagedVectorStore(**)`` → ``_check_vector_store_access`` → raise 404/403. Extract into ``_fetch_and_authorize_vector_store`` so the pattern lives in one place; future endpoints that need the same gate get it via one call. - The ``except HTTPException: raise`` guard added in the prior commit is retained — the helper raises HTTPException(403/404) and the catch-all ``except Exception`` would otherwise rewrite them as 500. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../management_endpoints.py | 71 ++++++++++--------- 1 file changed, 37 insertions(+), 34 deletions(-) diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index acfefff3028..9a70ac8b762 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -63,6 +63,33 @@ def _redact_sensitive_litellm_params( } +async def _fetch_and_authorize_vector_store( + vector_store_id: str, + user_api_key_dict: UserAPIKeyAuth, + prisma_client: Any, +) -> "LiteLLM_ManagedVectorStore": + """ + Look up a vector store by id and confirm the caller can access it. + Raises HTTPException(404) on miss and HTTPException(403) on access + denial. + """ + row = await prisma_client.db.litellm_managedvectorstorestable.find_unique( + where={"vector_store_id": vector_store_id} + ) + if row is None: + raise HTTPException( + status_code=404, + detail=f"Vector store with ID {vector_store_id} not found", + ) + typed = LiteLLM_ManagedVectorStore(**row.model_dump()) + if not await _check_vector_store_access(typed, user_api_key_dict): + raise HTTPException( + status_code=403, + detail="Access denied: You do not have permission to access this vector store", + ) + return typed + + def _resolve_embedding_config_from_router( embedding_model: str, llm_router ) -> Optional[Dict[str, Any]]: @@ -752,26 +779,12 @@ async def get_vector_store_info( ) return {"vector_store": vector_store_pydantic_obj} - vector_store = ( - await prisma_client.db.litellm_managedvectorstorestable.find_unique( - where={"vector_store_id": data.vector_store_id} - ) + vector_store_typed = await _fetch_and_authorize_vector_store( + vector_store_id=data.vector_store_id, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, ) - if vector_store is None: - raise HTTPException( - status_code=404, - detail=f"Vector store with ID {data.vector_store_id} not found", - ) - - # Check access control for DB vector store - vector_store_dict = vector_store.model_dump() # type: ignore[attr-defined] - vector_store_typed = LiteLLM_ManagedVectorStore(**vector_store_dict) - if not await _check_vector_store_access(vector_store_typed, user_api_key_dict): - raise HTTPException( - status_code=403, - detail="Access denied: You do not have permission to access this vector store", - ) - + vector_store_dict = dict(vector_store_typed) if "litellm_params" in vector_store_dict: vector_store_dict["litellm_params"] = _redact_sensitive_litellm_params( vector_store_dict["litellm_params"] @@ -809,22 +822,12 @@ async def update_vector_store( # 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} + # including stores belonging to other teams. + await _fetch_and_authorize_vector_store( + vector_store_id=vector_store_id, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, ) - 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: From 294ac8383e390726218e4804b92446f37845b91b Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Sat, 25 Apr 2026 06:10:45 +0000 Subject: [PATCH 5/6] fix(vector-stores): recurse into nested litellm_params; handle JSON-string shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues surfaced in review of the previous commit: 1. **Veria — Medium**: ``litellm_params`` carries a nested ``litellm_embedding_config`` dict (auto-resolved from the model registry on create / update) which itself holds ``api_key`` / ``aws_*`` / ``vertex_credentials``. The previous redactor only inspected top-level keys, so the nested values passed through unredacted. Recurse into nested dicts. 2. **Greptile — P2**: when ``litellm_params`` is a JSON-serialized string (the in-memory registry occasionally stores it that way), the previous redactor silently no-op'd via the ``isinstance(..., dict)`` guard and echoed the raw payload back. Now: parse, redact, re-serialize. If the string is not valid JSON, replace it with the redaction sentinel rather than echo it. 3. **mypy** flagged ``_redact_sensitive_litellm_params``'s ``Optional[Dict[str, Any]]`` signature as incompatible with the ``object``-typed call site. Widened to ``Any -> Any`` to reflect the actual contract (the function now handles dict / str / None / other). Also fixes a related test regression in ``test_remove_sensitive_info_from_deployment_with_excluded_keys``: the ``"credentials"`` plural addition to ``SensitiveDataMasker`` defaults caused the first call (without ``excluded_keys``) to mutate the input dict's ``litellm_credentials_name`` to a masked value. The second call (with ``excluded_keys``) then saw the already-masked value rather than the original. Construct fresh input for each call. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../management_endpoints.py | 41 +++++++---- .../test_openai_endpoint_utils.py | 4 +- .../test_vector_store_endpoints.py | 72 +++++++++++++++++++ 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index 9a70ac8b762..48d08a35d7c 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -43,24 +43,41 @@ router = APIRouter() _LITELLM_PARAMS_MASKER = SensitiveDataMasker() -def _redact_sensitive_litellm_params( - litellm_params: Optional[Dict[str, Any]], -) -> Optional[Dict[str, Any]]: +def _redact_sensitive_litellm_params(litellm_params: Any) -> Any: """ Replace credential-bearing values in ``litellm_params`` with ``REDACTED_BY_LITELM`` while preserving non-secret keys (``api_base``, ``region``, ``model``, ``api_version``). + + Handles three input shapes: + + * ``dict`` — recurse into nested dicts (e.g. ``litellm_embedding_config`` + which itself carries ``api_key`` / ``aws_*`` / ``vertex_credentials``). + * ``str`` — the in-memory registry occasionally holds the params as a + JSON-serialized string. Parse, redact, re-serialize. If parsing + fails, return the redaction sentinel rather than echo the value + back verbatim. + * Anything else, or ``None`` — passed through. """ - if not litellm_params or not isinstance(litellm_params, dict): + if litellm_params is None: + return None + if isinstance(litellm_params, str): + try: + parsed = json.loads(litellm_params) + except (TypeError, ValueError): + return REDACTED_BY_LITELM_STRING + return json.dumps(_redact_sensitive_litellm_params(parsed)) + if not isinstance(litellm_params, dict): return litellm_params - return { - k: ( - REDACTED_BY_LITELM_STRING - if _LITELLM_PARAMS_MASKER.is_sensitive_key(k) - else v - ) - for k, v in litellm_params.items() - } + out: Dict[str, Any] = {} + for k, v in litellm_params.items(): + if _LITELLM_PARAMS_MASKER.is_sensitive_key(k): + out[k] = REDACTED_BY_LITELM_STRING + elif isinstance(v, dict): + out[k] = _redact_sensitive_litellm_params(v) + else: + out[k] = v + return out async def _fetch_and_authorize_vector_store( diff --git a/tests/test_litellm/proxy/common_utils/test_openai_endpoint_utils.py b/tests/test_litellm/proxy/common_utils/test_openai_endpoint_utils.py index 44288b027ae..3e3e89f8117 100644 --- a/tests/test_litellm/proxy/common_utils/test_openai_endpoint_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_openai_endpoint_utils.py @@ -104,7 +104,9 @@ def test_remove_sensitive_info_from_deployment_with_excluded_keys(): assert sanitized_config["litellm_params"]["access_token"] != "token-12345" assert "*" in sanitized_config["litellm_params"]["access_token"] - # With excluded_keys, litellm_credentials_name should NOT be masked (even if it would match patterns) + # With excluded_keys, litellm_credentials_name should NOT be masked. + # ``remove_sensitive_info_from_deployment`` mutates its input, so feed it + # a fresh copy rather than the already-sanitized one. sanitized_config = remove_sensitive_info_from_deployment( copy.deepcopy(base_config), excluded_keys={"litellm_credentials_name"} ) 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 92027e0a6f2..3d369ed2247 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 @@ -1951,6 +1951,78 @@ class TestRedactSensitiveLitellmParams: _redact_sensitive_litellm_params(original) assert original == snapshot, "input dict must not be mutated" + def test_redacts_nested_credentials_in_embedding_config(self): + """ + ``/vector_store/new`` and ``/vector_store/update`` auto-resolve + ``litellm_embedding_config`` from the model registry and store it + as a nested dict inside ``litellm_params``. The nested dict carries + its own ``api_key`` / ``aws_*`` / ``vertex_credentials``, and a + non-recursive redactor would leak them. + """ + from litellm.constants import REDACTED_BY_LITELM_STRING + from litellm.proxy.vector_store_endpoints.management_endpoints import ( + _redact_sensitive_litellm_params, + ) + + params = { + "model": "openai/text-embedding-3-large", + "api_base": "https://api.openai.com/v1", + "litellm_embedding_config": { + "api_key": "sk-nested-secret", + "api_base": "https://nested.example.com", + "vertex_credentials": '{"private_key":"-----BEGIN..."}', + }, + } + out = _redact_sensitive_litellm_params(params) + nested = out["litellm_embedding_config"] + assert nested["api_key"] == REDACTED_BY_LITELM_STRING + assert nested["vertex_credentials"] == REDACTED_BY_LITELM_STRING + assert nested["api_base"] == "https://nested.example.com" + # Top-level non-secrets preserved + assert out["api_base"] == "https://api.openai.com/v1" + assert out["model"] == "openai/text-embedding-3-large" + + def test_redacts_json_string_litellm_params(self): + """ + The in-memory registry occasionally holds ``litellm_params`` as a + JSON-serialized string rather than a dict. The redactor must parse, + redact, and re-serialize so callers don't get the raw string back. + """ + import json as _json + + from litellm.constants import REDACTED_BY_LITELM_STRING + from litellm.proxy.vector_store_endpoints.management_endpoints import ( + _redact_sensitive_litellm_params, + ) + + params_json = _json.dumps( + { + "api_key": "sk-secret-from-json-string", + "api_base": "https://api.openai.com/v1", + } + ) + out = _redact_sensitive_litellm_params(params_json) + assert isinstance(out, str) + parsed = _json.loads(out) + assert parsed["api_key"] == REDACTED_BY_LITELM_STRING + assert parsed["api_base"] == "https://api.openai.com/v1" + + def test_redacts_unparseable_string_litellm_params(self): + """ + If ``litellm_params`` is a string that isn't valid JSON, the + redactor must NOT echo the value back verbatim — it could contain + opaque credential material. + """ + from litellm.constants import REDACTED_BY_LITELM_STRING + from litellm.proxy.vector_store_endpoints.management_endpoints import ( + _redact_sensitive_litellm_params, + ) + + out = _redact_sensitive_litellm_params( + "this is not json but might contain a secret" + ) + assert out == REDACTED_BY_LITELM_STRING + class TestUpdateVectorStoreAccessControlAndRedaction: """ From 4d92bc8b860d20479ade17b5c891a1ed322ffab9 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Sat, 25 Apr 2026 07:19:44 +0000 Subject: [PATCH 6/6] fix(vector-stores): re-raise HTTPException from get_vector_store_info; allowlist recursion Two issues from the previous push's review: 1. **Greptile P1**: ``get_vector_store_info`` had the same catch-all ``except Exception`` pattern as ``update_vector_store``, so the HTTPException(403/404) raised by both the in-memory access check and the new ``_fetch_and_authorize_vector_store`` helper was rewritten as 500. Mirror the ``except HTTPException: raise`` guard from ``update_vector_store``. 2. **code-quality CI** (``tests/code_coverage_tests/recursive_detector.py``) flagged ``_redact_sensitive_litellm_params`` as an unallowlisted recursive function. Match the convention of other allowlisted helpers ("max depth set"): bound recursion at depth 10 (well above any plausible nesting level for real ``litellm_params`` payloads), return the redaction sentinel on overflow, and add the function name to ``IGNORE_FUNCTIONS``. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../management_endpoints.py | 19 ++++++++++++++++--- .../code_coverage_tests/recursive_detector.py | 1 + 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index 48d08a35d7c..99a2085bfcd 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -43,7 +43,10 @@ router = APIRouter() _LITELLM_PARAMS_MASKER = SensitiveDataMasker() -def _redact_sensitive_litellm_params(litellm_params: Any) -> Any: +_REDACT_LITELLM_PARAMS_MAX_DEPTH = 10 + + +def _redact_sensitive_litellm_params(litellm_params: Any, _depth: int = 0) -> Any: """ Replace credential-bearing values in ``litellm_params`` with ``REDACTED_BY_LITELM`` while preserving non-secret keys (``api_base``, @@ -58,7 +61,13 @@ def _redact_sensitive_litellm_params(litellm_params: Any) -> Any: fails, return the redaction sentinel rather than echo the value back verbatim. * Anything else, or ``None`` — passed through. + + Recursion depth is bounded by ``_REDACT_LITELLM_PARAMS_MAX_DEPTH`` — + matching the convention of other allowlisted recursive helpers in the + repo (see ``tests/code_coverage_tests/recursive_detector.py``). """ + if _depth >= _REDACT_LITELLM_PARAMS_MAX_DEPTH: + return REDACTED_BY_LITELM_STRING if litellm_params is None: return None if isinstance(litellm_params, str): @@ -66,7 +75,7 @@ def _redact_sensitive_litellm_params(litellm_params: Any) -> Any: parsed = json.loads(litellm_params) except (TypeError, ValueError): return REDACTED_BY_LITELM_STRING - return json.dumps(_redact_sensitive_litellm_params(parsed)) + return json.dumps(_redact_sensitive_litellm_params(parsed, _depth + 1)) if not isinstance(litellm_params, dict): return litellm_params out: Dict[str, Any] = {} @@ -74,7 +83,7 @@ def _redact_sensitive_litellm_params(litellm_params: Any) -> Any: if _LITELLM_PARAMS_MASKER.is_sensitive_key(k): out[k] = REDACTED_BY_LITELM_STRING elif isinstance(v, dict): - out[k] = _redact_sensitive_litellm_params(v) + out[k] = _redact_sensitive_litellm_params(v, _depth + 1) else: out[k] = v return out @@ -807,6 +816,10 @@ async def get_vector_store_info( vector_store_dict["litellm_params"] ) return {"vector_store": vector_store_dict} + except HTTPException: + # Preserve 403/404 from the access-control / not-found checks above; + # the catch-all below would otherwise rewrite them as 500. + raise except Exception as e: verbose_proxy_logger.exception(f"Error getting vector store info: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index fc9c99f6afc..07af2735dfe 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -46,6 +46,7 @@ IGNORE_FUNCTIONS = [ "dict", # max depth set. _LiteLLMParamsDictView.dict() calls builtin dict(), not itself. "_read_image_bytes", # max depth set. "_get_masked_values", # max depth set (default 20) to prevent infinite recursion while masking nested sensitive config dicts. + "_redact_sensitive_litellm_params", # max depth set (default 10). ]