mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
commit
00d71f586c
5 changed files with 397 additions and 22 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue