mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
fix: redact API keys from error responses
API keys from LLM providers (OpenAI, Anthropic, Azure, etc.) can leak into error messages when provider APIs return errors that include the request's authentication details. This is a security concern for enterprise customers. Changes: - Added litellm/litellm_core_utils/redact_api_keys.py with regex-based redaction for common API key formats (sk-*, Bearer tokens, URL key params, Azure api-key headers, Authorization headers) - Applied redaction in exception_mapping_utils.py where provider error messages are extracted and mapped to litellm exceptions - Applied redaction in ProxyException.__init__ as a safety net for all error responses sent to clients - Applied redaction in MaskedHTTPStatusError for HTTP-level errors - Added comprehensive test suite with 16 test cases
This commit is contained in:
parent
0a1b98895b
commit
606436c37c
5 changed files with 190 additions and 6 deletions
|
|
@ -25,6 +25,7 @@ from ..exceptions import (
|
|||
Timeout,
|
||||
UnprocessableEntityError,
|
||||
)
|
||||
from litellm.litellm_core_utils.redact_api_keys import redact_api_keys
|
||||
|
||||
|
||||
class ExceptionCheckers:
|
||||
|
|
@ -251,10 +252,10 @@ def exception_type( # type: ignore # noqa: PLR0915
|
|||
original_exception=original_exception
|
||||
)
|
||||
try:
|
||||
error_str = str(original_exception)
|
||||
error_str = redact_api_keys(str(original_exception))
|
||||
if model:
|
||||
if hasattr(original_exception, "message"):
|
||||
error_str = str(original_exception.message)
|
||||
error_str = redact_api_keys(str(original_exception.message))
|
||||
if isinstance(original_exception, BaseException):
|
||||
exception_type = type(original_exception).__name__
|
||||
else:
|
||||
|
|
@ -360,6 +361,9 @@ def exception_type( # type: ignore # noqa: PLR0915
|
|||
else:
|
||||
message = str(original_exception)
|
||||
|
||||
# Redact any API keys that may have leaked into the error message
|
||||
message = redact_api_keys(message)
|
||||
|
||||
if message is not None and isinstance(
|
||||
message, str
|
||||
): # done to prevent user-confusion. Relevant issue - https://github.com/BerriAI/litellm/issues/1414
|
||||
|
|
@ -2051,6 +2055,9 @@ def exception_type( # type: ignore # noqa: PLR0915
|
|||
else:
|
||||
message = str(original_exception)
|
||||
|
||||
# Redact any API keys that may have leaked into the error message
|
||||
message = redact_api_keys(message)
|
||||
|
||||
# Azure OpenAI (especially Images) often nests error details under
|
||||
# body["error"]. Detect content policy violations using the structured
|
||||
# payload in addition to string matching.
|
||||
|
|
|
|||
68
litellm/litellm_core_utils/redact_api_keys.py
Normal file
68
litellm/litellm_core_utils/redact_api_keys.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# +-----------------------------------------------+
|
||||
# | |
|
||||
# | Give Feedback / Get Help |
|
||||
# | https://github.com/BerriAI/litellm/issues/new |
|
||||
# | |
|
||||
# +-----------------------------------------------+
|
||||
#
|
||||
# Thank you users! We ❤️ you! - Krrish & Ishaan
|
||||
|
||||
"""
|
||||
Utility to redact API keys from error messages.
|
||||
|
||||
API keys from various providers can leak into error messages when provider
|
||||
APIs return errors that include the request's authentication details.
|
||||
This module provides functions to detect and mask such keys before they
|
||||
reach end users.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
# Pattern to match common API key formats in error messages
|
||||
# Covers: sk-xxx, Bearer xxx, key=xxx in URLs, and common provider key patterns
|
||||
_API_KEY_PATTERNS = [
|
||||
# Anthropic keys: sk-ant-<key> (must be before generic sk- pattern)
|
||||
(re.compile(r"(sk-ant-)[A-Za-z0-9_-]{20,}"), r"\g<1>****"),
|
||||
# OpenAI-style keys: sk-<anything that looks like a key>
|
||||
# Match sk- followed by at least 20 chars of key material
|
||||
(re.compile(r"(sk-)[A-Za-z0-9_-]{20,}"), r"\g<1>****"),
|
||||
# Bearer token in messages: Bearer <token>
|
||||
(re.compile(r"(Bearer\s+)[A-Za-z0-9_.+/=-]{10,}"), r"\g<1>[REDACTED]"),
|
||||
# Authorization header value patterns
|
||||
(re.compile(r"(Authorization['\"]?\s*[:=]\s*['\"]?)[A-Za-z0-9_.+/=-]{10,}"), r"\g<1>[REDACTED]"),
|
||||
# URL query param: key=<value> or api_key=<value> or apikey=<value>
|
||||
(re.compile(r"((?:api[_-]?key|key|token|secret|password|credential|auth)=)[A-Za-z0-9_.+/=-]{8,}(?=&|$|\s|['\"])"), r"\g<1>[REDACTED]"),
|
||||
# Azure API keys (32-char hex strings preceded by api-key header context)
|
||||
(re.compile(r"(api-key['\"]?\s*[:=]\s*['\"]?)[a-f0-9]{32,}"), r"\g<1>[REDACTED]"),
|
||||
# Generic long hex/base64 strings that look like keys when preceded by key-related words
|
||||
(re.compile(r"((?:api[_-]?key|secret|token|credential|auth[_-]?token)['\"]?\s*[:=]\s*['\"]?)[A-Za-z0-9_.+/=-]{16,}"), r"\g<1>[REDACTED]"),
|
||||
]
|
||||
|
||||
|
||||
def redact_api_keys(message: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Redact API keys from an error message string.
|
||||
|
||||
Scans the message for patterns that look like API keys and replaces them
|
||||
with redacted placeholders. This prevents leaking sensitive credentials
|
||||
in error responses returned to users.
|
||||
|
||||
Args:
|
||||
message: The error message string to redact.
|
||||
|
||||
Returns:
|
||||
The message with API keys redacted, or the original message if no keys found.
|
||||
Returns None if input is None.
|
||||
"""
|
||||
if message is None:
|
||||
return None
|
||||
|
||||
if not isinstance(message, str):
|
||||
return message
|
||||
|
||||
redacted = message
|
||||
for pattern, replacement in _API_KEY_PATTERNS:
|
||||
redacted = pattern.sub(replacement, redacted)
|
||||
|
||||
return redacted
|
||||
|
|
@ -317,12 +317,14 @@ class MaskedHTTPStatusError(httpx.HTTPStatusError):
|
|||
def __init__(
|
||||
self, original_error, message: Optional[str] = None, text: Optional[str] = None
|
||||
):
|
||||
from litellm.litellm_core_utils.redact_api_keys import redact_api_keys
|
||||
|
||||
# Create a new error with the masked URL
|
||||
masked_url = mask_sensitive_info(str(original_error.request.url))
|
||||
# Create a new error that looks like the original, but with a masked URL
|
||||
|
||||
super().__init__(
|
||||
message=original_error.message,
|
||||
message=redact_api_keys(original_error.message) or original_error.message,
|
||||
request=httpx.Request(
|
||||
method=original_error.request.method,
|
||||
url=masked_url,
|
||||
|
|
@ -335,8 +337,9 @@ class MaskedHTTPStatusError(httpx.HTTPStatusError):
|
|||
headers=original_error.response.headers,
|
||||
),
|
||||
)
|
||||
self.message = message
|
||||
self.text = text
|
||||
# Redact API keys from message and text fields
|
||||
self.message = redact_api_keys(message) if message else message
|
||||
self.text = redact_api_keys(text) if text else text
|
||||
|
||||
|
||||
class AsyncHTTPHandler:
|
||||
|
|
|
|||
|
|
@ -2958,7 +2958,9 @@ class ProxyException(Exception):
|
|||
openai_code: Optional[str] = None, # maps to 'code' in openai
|
||||
provider_specific_fields: Optional[dict] = None,
|
||||
):
|
||||
self.message = str(message)
|
||||
from litellm.litellm_core_utils.redact_api_keys import redact_api_keys
|
||||
|
||||
self.message = redact_api_keys(str(message)) or str(message)
|
||||
self.type = type
|
||||
self.param = param
|
||||
self.openai_code = openai_code or code
|
||||
|
|
|
|||
104
tests/litellm/litellm_core_utils/test_redact_api_keys.py
Normal file
104
tests/litellm/litellm_core_utils/test_redact_api_keys.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""
|
||||
Tests for the API key redaction utility.
|
||||
|
||||
Ensures that various API key formats are properly masked in error messages
|
||||
to prevent credential leakage to end users.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.litellm_core_utils.redact_api_keys import redact_api_keys
|
||||
|
||||
|
||||
class TestRedactApiKeys:
|
||||
"""Tests for the redact_api_keys function."""
|
||||
|
||||
def test_returns_none_for_none_input(self):
|
||||
assert redact_api_keys(None) is None
|
||||
|
||||
def test_returns_non_string_as_is(self):
|
||||
assert redact_api_keys(123) == 123
|
||||
|
||||
def test_no_keys_returns_unchanged(self):
|
||||
msg = "Connection timeout after 30 seconds"
|
||||
assert redact_api_keys(msg) == msg
|
||||
|
||||
def test_redacts_openai_sk_key(self):
|
||||
msg = "Error: Invalid API key: sk-1234567890abcdefghijklmnopqrstuv"
|
||||
result = redact_api_keys(msg)
|
||||
assert "sk-1234567890abcdefghijklmnopqrstuv" not in result
|
||||
assert "sk-****" in result
|
||||
|
||||
def test_redacts_anthropic_sk_ant_key(self):
|
||||
msg = "AuthenticationError: Invalid API key: sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890"
|
||||
result = redact_api_keys(msg)
|
||||
assert "sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890" not in result
|
||||
assert "sk-ant-****" in result
|
||||
|
||||
def test_redacts_bearer_token(self):
|
||||
msg = "Authorization failed with Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0"
|
||||
result = redact_api_keys(msg)
|
||||
assert "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" not in result
|
||||
assert "Bearer [REDACTED]" in result
|
||||
|
||||
def test_redacts_key_in_url_param(self):
|
||||
msg = "Request to https://api.example.com/v1/chat?key=abcdef1234567890ghij failed"
|
||||
result = redact_api_keys(msg)
|
||||
assert "abcdef1234567890ghij" not in result
|
||||
assert "key=[REDACTED]" in result
|
||||
|
||||
def test_redacts_api_key_url_param(self):
|
||||
msg = "Error calling https://api.example.com?api_key=sk_test_abc123def456ghi789 - 401"
|
||||
result = redact_api_keys(msg)
|
||||
assert "sk_test_abc123def456ghi789" not in result
|
||||
assert "api_key=[REDACTED]" in result
|
||||
|
||||
def test_redacts_azure_api_key_header(self):
|
||||
msg = "api-key: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 was rejected"
|
||||
result = redact_api_keys(msg)
|
||||
assert "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" not in result
|
||||
assert "api-key: [REDACTED]" in result
|
||||
|
||||
def test_redacts_authorization_header_value(self):
|
||||
msg = "Authorization: sk-proj-abcdefghijklmnopqrstuvwxyz123456"
|
||||
result = redact_api_keys(msg)
|
||||
assert "sk-proj-abcdefghijklmnopqrstuvwxyz123456" not in result
|
||||
|
||||
def test_preserves_error_context(self):
|
||||
msg = "OpenAIException - Error code: 401 - Invalid API key provided: sk-abcdefghijklmnopqrstuvwxyz12345678. You can find your API key at https://platform.openai.com/account/api-keys."
|
||||
result = redact_api_keys(msg)
|
||||
assert "sk-abcdefghijklmnopqrstuvwxyz12345678" not in result
|
||||
assert "Error code: 401" in result
|
||||
assert "Invalid API key provided" in result
|
||||
|
||||
def test_redacts_multiple_keys_in_same_message(self):
|
||||
msg = "Tried key sk-aaaabbbbccccddddeeeeffffgggg1234 then sk-1111222233334444555566667777abcd"
|
||||
result = redact_api_keys(msg)
|
||||
assert "sk-aaaabbbbccccddddeeeeffffgggg1234" not in result
|
||||
assert "sk-1111222233334444555566667777abcd" not in result
|
||||
|
||||
def test_real_world_openai_error_with_key(self):
|
||||
"""Simulate a real OpenAI error that includes the API key in the message."""
|
||||
msg = (
|
||||
"Error code: 401 - {'error': {'message': 'Incorrect API key provided: sk-proj-aBcDeFgHiJkLmNoPqRsTuVwXyZ123456. "
|
||||
"You can find your API key at https://platform.openai.com/account/api-keys.', "
|
||||
"'type': 'invalid_request_error', 'param': None, 'code': 'invalid_api_key'}}"
|
||||
)
|
||||
result = redact_api_keys(msg)
|
||||
assert "sk-proj-aBcDeFgHiJkLmNoPqRsTuVwXyZ123456" not in result
|
||||
assert "sk-" in result # prefix should still be visible
|
||||
assert "invalid_api_key" in result # error context preserved
|
||||
|
||||
def test_real_world_azure_error_with_key(self):
|
||||
"""Simulate an Azure error that includes the API key."""
|
||||
msg = "Access denied with api-key=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6. Check your credentials."
|
||||
result = redact_api_keys(msg)
|
||||
assert "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" not in result
|
||||
|
||||
def test_empty_string(self):
|
||||
assert redact_api_keys("") == ""
|
||||
|
||||
def test_short_values_not_redacted(self):
|
||||
"""Short values that don't look like keys should not be redacted."""
|
||||
msg = "key=abc is too short"
|
||||
assert redact_api_keys(msg) == msg
|
||||
Loading…
Add table
Reference in a new issue