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 fefa6cb4e94..99a2085bfcd 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,81 @@ from litellm.vector_stores.vector_store_registry import VectorStoreRegistry router = APIRouter() +_LITELLM_PARAMS_MASKER = SensitiveDataMasker() + + +_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``, + ``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. + + 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): + try: + parsed = json.loads(litellm_params) + except (TypeError, ValueError): + return REDACTED_BY_LITELM_STRING + return json.dumps(_redact_sensitive_litellm_params(parsed, _depth + 1)) + if not isinstance(litellm_params, dict): + return litellm_params + 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, _depth + 1) + else: + out[k] = v + return out + + +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 @@ -555,7 +632,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(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 @@ -716,33 +797,29 @@ 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, ) 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", + 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"] ) - - # 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", - ) - 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)) @@ -773,6 +850,15 @@ 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. + await _fetch_and_authorize_vector_store( + vector_store_id=vector_store_id, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + ) + # Handle metadata serialization if update_data.get("vector_store_metadata") is not None: update_data["vector_store_metadata"] = safe_dumps( @@ -820,11 +906,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/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). ] 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 44cc5cc4452..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 @@ -1882,3 +1882,274 @@ 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_redaction_does_not_mutate_input_litellm_params(self): + from litellm.proxy.vector_store_endpoints.management_endpoints import ( + _redact_sensitive_litellm_params, + ) + + original = { + "api_key": "sk-real-openai-key-12345", + "api_base": "https://api.openai.com/v1", + } + snapshot = dict(original) + _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: + """ + ``/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"