mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
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:
parent
0a1b98895b
commit
648b7a74b5
4 changed files with 153 additions and 5 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
136
tests/test_litellm/proxy/test_api_key_masking_in_errors.py
Normal file
136
tests/test_litellm/proxy/test_api_key_masking_in_errors.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue