fix(langfuse): strip callback credentials from user_api_key_auth_metadata at source

Root cause: get_sanitized_user_information_from_key() passed user_api_key_dict.metadata
(which contains the raw 'logging' key with langfuse_secret_key, api keys, etc.) directly
into user_api_key_auth_metadata. This dict then propagated to every logger integration
via StandardLoggingUserAPIKeyMetadata without ever being scrubbed.

Structural fixes (three layers of defense):

1. _safe_key_metadata() in litellm_pre_call_utils.py: strips 'logging' and
   'callback_settings' from key metadata before it becomes user_api_key_auth_metadata.
   Credentials are now removed at the earliest possible point — before scatter.

2. langfuse.py _log_langfuse_v2: add 'user_api_key_auth_metadata' to the clean_metadata
   exclusion list, alongside 'user_api_key_auth'. Belt-and-suspenders for Langfuse.

3. langfuse.py: use standard_logging_object['model_parameters'] (OpenAI-spec whitelist)
   instead of raw optional_params for modelParameters. Structurally prevents any
   callback credential from reaching modelParameters regardless of future key types.

These fixes are general: they protect against leaking any credential type (LLM api keys,
virtual keys, etc.), not just langfuse_secret_key.
This commit is contained in:
Ishaan Jaffer 2026-03-09 15:58:20 -07:00
parent 35a5381b83
commit 8efb70e7a0
3 changed files with 118 additions and 4 deletions

View file

@ -285,7 +285,18 @@ class LangFuseLogger:
litellm_params.get("metadata", {}) or {}
) # if litellm_params['metadata'] == None
metadata = self.add_metadata_from_header(litellm_params, metadata)
optional_params = safe_deep_copy(kwargs.get("optional_params", {}))
# Prefer model_parameters from standard_logging_object — it is pre-filtered
# through ModelParamHelper.get_standard_logging_model_parameters() which
# whitelists only real OpenAI API params. This structurally prevents callback
# credentials (langfuse_secret_key, api keys, etc.) from ever reaching
# modelParameters, regardless of what per-key callback_vars are set.
_standard_logging_object = kwargs.get("standard_logging_object")
if _standard_logging_object is not None:
optional_params = dict(
_standard_logging_object.get("model_parameters") or {}
)
else:
optional_params = safe_deep_copy(kwargs.get("optional_params", {}))
prompt = {"messages": kwargs.get("messages")}
@ -293,8 +304,8 @@ class LangFuseLogger:
tools = optional_params.pop("tools", None)
# Remove secret_fields to prevent leaking sensitive data (e.g., authorization headers)
optional_params.pop("secret_fields", None)
# Remove Langfuse credential keys - these are per-key/team callback vars that should
# not appear in modelParameters (they would expose secret keys in traces)
# Belt-and-suspenders: also pop any credential keys that may be present
# in the fallback path (direct litellm usage without standard_logging_object)
optional_params.pop("langfuse_secret_key", None)
optional_params.pop("langfuse_secret", None)
optional_params.pop("langfuse_public_key", None)
@ -598,6 +609,15 @@ class LangFuseLogger:
"endpoint",
"caching_groups",
"previous_models",
# user_api_key_auth is the full UserAPIKeyAuth object — it contains
# metadata.logging[].callback_vars with per-key secrets such as
# langfuse_secret_key, api keys, etc. Never log this object.
"user_api_key_auth",
# user_api_key_auth_metadata carries the raw virtual-key metadata
# dict including the `logging` key with callback_vars/credentials.
# The per-key identity fields (alias, team, spend, etc.) are already
# captured in the dedicated user_api_key_* generation fields.
"user_api_key_auth_metadata",
]:
continue
else:

View file

@ -667,10 +667,29 @@ class LiteLLMProxyRequestSetup:
if user_api_key_dict.budget_reset_at
else None
),
user_api_key_auth_metadata=user_api_key_dict.metadata,
user_api_key_auth_metadata=LiteLLMProxyRequestSetup._safe_key_metadata(
user_api_key_dict.metadata
),
)
return user_api_key_logged_metadata
@staticmethod
def _safe_key_metadata(metadata: Optional[dict]) -> Optional[dict]:
"""
Returns a copy of the virtual-key metadata with sensitive callback config stripped.
The `logging` key contains per-key callback_vars (langfuse_secret_key, api keys,
etc.) that must never be propagated into observability payloads. Similarly,
`callback_settings` holds team-level callback secrets. Strip both before the dict
is stored in StandardLoggingUserAPIKeyMetadata and propagated to every logger.
"""
if not metadata:
return metadata
safe = dict(metadata)
safe.pop("logging", None)
safe.pop("callback_settings", None)
return safe
@staticmethod
def add_user_api_key_auth_to_request_metadata(
data: dict,

View file

@ -1817,3 +1817,78 @@ async def test_bearer_token_not_in_debug_logs():
f"Bearer token leaked in debug logs. "
f"Found token in log output:\n{log_output[:500]}"
)
def test_safe_key_metadata_strips_logging_key():
"""
_safe_key_metadata must strip the `logging` key (contains raw callback_vars /
credentials) before the dict is stored in StandardLoggingUserAPIKeyMetadata
and propagated to every logger integration.
"""
raw_metadata = {
"team": "engineering",
"logging": [
{
"callback_name": "langfuse",
"langfuse_secret_key": "sk-lf-super-secret",
"langfuse_public_key": "pk-lf-public",
}
],
}
result = LiteLLMProxyRequestSetup._safe_key_metadata(raw_metadata)
assert result is not None
assert "logging" not in result, "logging key with raw credentials must be stripped"
assert result.get("team") == "engineering", "non-sensitive keys must be preserved"
def test_safe_key_metadata_strips_callback_settings_key():
"""
_safe_key_metadata must strip `callback_settings` (team-level callback secrets).
"""
raw_metadata = {
"project": "demo",
"callback_settings": {
"langfuse": {"langfuse_secret_key": "sk-lf-team-secret"}
},
}
result = LiteLLMProxyRequestSetup._safe_key_metadata(raw_metadata)
assert result is not None
assert "callback_settings" not in result, "callback_settings must be stripped"
assert result.get("project") == "demo"
def test_safe_key_metadata_none_and_empty():
"""
_safe_key_metadata must handle None and empty dicts without error.
"""
assert LiteLLMProxyRequestSetup._safe_key_metadata(None) is None
assert LiteLLMProxyRequestSetup._safe_key_metadata({}) == {}
def test_get_sanitized_user_information_from_key_excludes_credentials():
"""
get_sanitized_user_information_from_key must not expose per-key callback
credentials (e.g. langfuse_secret_key) in user_api_key_auth_metadata.
"""
user_api_key_dict = UserAPIKeyAuth(
api_key="hashed-test-key",
key_alias="my-key",
metadata={
"team": "engineering",
"logging": [
{
"callback_name": "langfuse",
"langfuse_secret_key": "sk-lf-should-not-appear",
}
],
},
)
result = LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(
user_api_key_dict
)
auth_metadata = result.get("user_api_key_auth_metadata") or {}
assert "logging" not in auth_metadata, (
"logging key (contains raw credentials) must not appear in "
"user_api_key_auth_metadata exposed to loggers"
)
assert auth_metadata.get("team") == "engineering"