fix: mask API keys in error responses for invalid/malformed keys

Fixes AT&T customer issue where API keys are returned in plain text
in error responses.

Changes:
1. user_api_key_auth.py: Mask the API key in the AssertionError when a
   key doesn't start with 'sk-' (e.g. key with leading space). Shows
   first 4 + last 4 chars with **** in between instead of the full key.

2. key_management_endpoints.py: Same masking for the key format
   validation error when creating keys with invalid prefix.

3. presidio.py: Sanitize exceptions from Presidio analyze/anonymize
   calls to prevent leaking original request text (which may contain
   API keys) in error responses. Error messages now show only the
   exception type, not the full payload.
This commit is contained in:
shin-bot-litellm 2026-02-02 19:24:18 +00:00
parent 0a1b98895b
commit 648b7a74b5
4 changed files with 153 additions and 5 deletions

View file

@ -898,10 +898,11 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
if isinstance(
api_key, str
): # if generated token, make sure it starts with sk-.
_masked_key = "{}****{}".format(api_key[:4], api_key[-4:]) if len(api_key) > 8 else "****"
assert api_key.startswith(
"sk-"
), "LiteLLM Virtual Key expected. Received={}, expected to start with 'sk-'.".format(
api_key
_masked_key
) # prevent token hashes from being used
else:
verbose_logger.warning(

View file

@ -348,7 +348,11 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
continue
return final_results
except Exception as e:
raise e
# Sanitize exception to avoid leaking the original text (which may
# contain API keys or other secrets) in error responses.
raise Exception(
f"Presidio PII analysis failed: {type(e).__name__}"
) from e
async def anonymize_text(
self,
@ -405,9 +409,15 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
)
return redacted_text["text"]
else:
raise Exception(f"Invalid anonymizer response: {redacted_text}")
raise Exception("Invalid anonymizer response: received None")
except Exception as e:
raise e
# Sanitize exception to avoid leaking the original text (which may
# contain API keys or other secrets) in error responses.
if "Invalid anonymizer response" in str(e):
raise
raise Exception(
f"Presidio PII anonymization failed: {type(e).__name__}"
) from e
def filter_analyze_results_by_score(
self, analyze_results: Union[List[PresidioAnalyzeResponseItem], Dict]

View file

@ -628,10 +628,11 @@ async def _common_key_generation_helper( # noqa: PLR0915
# Validate user-provided key format
if data.key is not None and not data.key.startswith("sk-"):
_masked = "{}****{}".format(data.key[:4], data.key[-4:]) if len(data.key) > 8 else "****"
raise HTTPException(
status_code=400,
detail={
"error": f"Invalid key format. LiteLLM Virtual Key must start with 'sk-'. Received: {data.key}"
"error": f"Invalid key format. LiteLLM Virtual Key must start with 'sk-'. Received: {_masked}"
},
)

View file

@ -0,0 +1,136 @@
"""
Tests that API keys are masked in error responses.
When an invalid/malformed API key is sent (e.g., with a leading space or
wrong prefix), the error response must NOT return the key in plain text.
Instead, it should show only the first 4 and last 4 characters with ****
in the middle.
"""
import pytest
class TestKeyMaskingInAuthErrors:
"""Test that user_api_key_auth masks keys in validation error messages."""
def test_assert_message_masks_key_without_sk_prefix(self):
"""
When a key doesn't start with 'sk-', the AssertionError message
should contain a masked version, not the full key.
"""
from litellm.proxy.auth.auth_utils import abbreviate_api_key
# Simulate the logic from user_api_key_auth.py
api_key = "my-secret-api-key-1234567890abcdef"
_masked_key = (
"{}****{}".format(api_key[:4], api_key[-4:])
if len(api_key) > 8
else "****"
)
# The masked key should NOT contain the full original key
assert api_key not in _masked_key
# Should show first 4 and last 4 chars
assert _masked_key == "my-s****cdef"
def test_assert_message_masks_key_with_leading_space(self):
"""
Reported case: key with leading space like ' sk-abc123...'
"""
api_key = " sk-abc123def456ghi789jkl012mno345pqr"
_masked_key = (
"{}****{}".format(api_key[:4], api_key[-4:])
if len(api_key) > 8
else "****"
)
assert api_key not in _masked_key
assert _masked_key == " sk-****5pqr"
def test_assert_message_masks_short_key(self):
"""Short keys (<=8 chars) should be fully masked."""
api_key = "short"
_masked_key = (
"{}****{}".format(api_key[:4], api_key[-4:])
if len(api_key) > 8
else "****"
)
assert _masked_key == "****"
def test_key_not_starting_with_sk_raises_masked_error(self):
"""
Verify the assert message format contains masked key, not the original.
Note: Python's AssertionError str(e) includes the expression + message,
but the *message* part (which is what gets passed to ProxyException)
should only contain the masked key.
"""
api_key = "bad-key-format-1234567890abcdefghijklmnop"
_masked_key = (
"{}****{}".format(api_key[:4], api_key[-4:])
if len(api_key) > 8
else "****"
)
# Build the same message string that user_api_key_auth.py would produce
error_message = "LiteLLM Virtual Key expected. Received={}, expected to start with 'sk-'.".format(
_masked_key
)
# The full key must NOT appear in the message
assert api_key not in error_message
# The masked version should appear
assert _masked_key in error_message
# Should still have helpful context
assert "expected to start with 'sk-'" in error_message
class TestKeyMaskingInKeyManagement:
"""Test that key_management_endpoints masks keys in validation errors."""
def test_invalid_key_format_error_is_masked(self):
"""
When creating a key that doesn't start with 'sk-', the error
should not include the full key value.
"""
key_value = "bad-prefix-1234567890abcdefghijklmnop"
_masked = (
"{}****{}".format(key_value[:4], key_value[-4:])
if len(key_value) > 8
else "****"
)
error_msg = f"Invalid key format. LiteLLM Virtual Key must start with 'sk-'. Received: {_masked}"
# Full key must not appear
assert key_value not in error_msg
# Masked version should appear
assert _masked in error_msg
assert "bad-****mnop" in error_msg
class TestPresidioErrorSanitization:
"""Test that Presidio errors don't leak request text containing keys."""
def test_analyze_text_error_does_not_leak_text(self):
"""
If Presidio analyzer fails, the error message should NOT contain
the original text that was being analyzed.
"""
# Simulate what happens: user message contains an API key,
# Presidio fails, error message should be sanitized
original_text = "Please use this key: sk-secret1234567890abcdefghijklmnop"
# The sanitized exception from our fix
sanitized_error = f"Presidio PII analysis failed: ConnectionError"
assert original_text not in sanitized_error
assert "sk-secret1234567890abcdefghijklmnop" not in sanitized_error
def test_anonymize_text_error_does_not_leak_text(self):
"""
If Presidio anonymizer fails, the error should be sanitized.
"""
sanitized_error = f"Presidio PII anonymization failed: ClientError"
assert "sk-" not in sanitized_error
assert "api_key" not in sanitized_error