From 844c43bb635e6f6b791e4505f07d7852ef930ef3 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 17 Apr 2026 17:26:16 -0400 Subject: [PATCH] fix(security): encrypt callback_vars on POST and redact on GET in team callback endpoint --- .../team_callback_endpoints.py | 16 ++- .../test_team_callback_endpoints.py | 130 ++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index 4eec7c6b7c0..d36be2f5d8e 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -19,6 +19,10 @@ from litellm.proxy._types import ( UserAPIKeyAuth, ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.common_utils.callback_utils import ( + encrypt_logging_callback_vars, + redact_sensitive_logging_metadata, +) from litellm.proxy.management_helpers.utils import management_endpoint_wrapper router = APIRouter() @@ -126,6 +130,7 @@ async def add_team_callbacks( team_callback_settings.append(data.model_dump()) team_metadata["logging"] = team_callback_settings + encrypt_logging_callback_vars(team_metadata) team_metadata_json = json.dumps(team_metadata) # update team_metadata new_team_row = await prisma_client.db.litellm_teamtable.update( @@ -312,13 +317,22 @@ async def get_team_callbacks( # Convert to TeamCallbackMetadata object for consistent structure team_callback_settings_obj = TeamCallbackMetadata(**team_callback_settings) + redacted_metadata = redact_sensitive_logging_metadata( + {"logging": [{"callback_vars": team_callback_settings_obj.callback_vars}]} + ) + redacted_vars = ( + redacted_metadata["logging"][0]["callback_vars"] + if redacted_metadata + else team_callback_settings_obj.callback_vars + ) + return { "status": "success", "data": { "team_id": team_id, "success_callbacks": team_callback_settings_obj.success_callback, "failure_callbacks": team_callback_settings_obj.failure_callback, - "callback_vars": team_callback_settings_obj.callback_vars, + "callback_vars": redacted_vars, }, } diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py new file mode 100644 index 00000000000..cd515903452 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py @@ -0,0 +1,130 @@ +import json +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../../../..")) + +from litellm.proxy.management_endpoints.team_callback_endpoints import ( + add_team_callbacks, + get_team_callbacks, +) +from litellm.proxy._types import AddTeamCallback, UserAPIKeyAuth + + +def _make_team(metadata: dict) -> MagicMock: + team = MagicMock() + team.metadata = metadata + return team + + +def _make_request() -> MagicMock: + req = MagicMock() + req.headers = {} + return req + + +# --------------------------------------------------------------------------- +# POST /team/{team_id}/callback — encrypt on write +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@patch("litellm.proxy.proxy_server.prisma_client") +async def test_add_team_callback_encrypts_callback_vars(mock_prisma, monkeypatch): + """callback_vars must be encrypted before writing to DB.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-key-1234567890123456") + + plaintext_secret = "sk-lf-supersecret99" + existing_team = _make_team({"logging": []}) + mock_prisma.get_data = AsyncMock(return_value=existing_team) + + captured = {} + + async def fake_update(where, data): + captured["data"] = data + row = MagicMock() + row.metadata = data.get("metadata", "{}") + return row + + mock_prisma.db = MagicMock() + mock_prisma.db.litellm_teamtable = MagicMock() + mock_prisma.db.litellm_teamtable.update = fake_update + + data = AddTeamCallback( + callback_name="langfuse", + callback_type="success", + callback_vars={"langfuse_secret_key": plaintext_secret}, + ) + + await add_team_callbacks( + data=data, + http_request=_make_request(), + team_id="team-123", + user_api_key_dict=UserAPIKeyAuth(), + ) + + written_metadata = json.loads(captured["data"]["metadata"]) + stored_secret = written_metadata["logging"][0]["callback_vars"]["langfuse_secret_key"] + assert stored_secret != plaintext_secret, "plaintext secret must be encrypted before DB write" + + +# --------------------------------------------------------------------------- +# GET /team/{team_id}/callback — redact on read +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@patch("litellm.proxy.proxy_server.prisma_client") +async def test_get_team_callbacks_redacts_callback_vars(mock_prisma, monkeypatch): + """callback_vars must be redacted (last-3-chars) in the GET response.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-key-1234567890123456") + + existing_team = _make_team( + { + "callback_settings": { + "success_callback": ["langfuse"], + "callback_vars": {"langfuse_secret_key": "sk-lf-supersecret99"}, + } + } + ) + mock_prisma.get_data = AsyncMock(return_value=existing_team) + + result = await get_team_callbacks( + http_request=_make_request(), + team_id="team-123", + user_api_key_dict=UserAPIKeyAuth(), + ) + + secret = result["data"]["callback_vars"]["langfuse_secret_key"] + assert secret != "sk-lf-supersecret99", "plaintext secret must not be returned" + assert secret.startswith("..."), "redacted value must use ...XYZ format" + + +@pytest.mark.asyncio +@patch("litellm.proxy.proxy_server.prisma_client") +async def test_get_team_callbacks_keeps_env_var_pointers(mock_prisma): + """os.environ/ references must pass through unredacted.""" + existing_team = _make_team( + { + "callback_settings": { + "callback_vars": { + "langfuse_secret_key": "os.environ/LANGFUSE_SECRET_KEY" + }, + } + } + ) + mock_prisma.get_data = AsyncMock(return_value=existing_team) + + result = await get_team_callbacks( + http_request=_make_request(), + team_id="team-123", + user_api_key_dict=UserAPIKeyAuth(), + ) + + assert ( + result["data"]["callback_vars"]["langfuse_secret_key"] + == "os.environ/LANGFUSE_SECRET_KEY" + )