Merge pull request #26489 from stuxf/fix/vector-store-list-cred-leak

chore(vector-stores): redact credentials in list/info/update responses; gate update by per-store access
This commit is contained in:
yuneng-jiang 2026-04-29 19:28:32 -07:00 committed by GitHub
commit 00d71f586c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 397 additions and 22 deletions

View file

@ -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",

View file

@ -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))

View file

@ -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).
]

View file

@ -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"}
)

View file

@ -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"