From 606436c37cbecec59d767807cf5d6ce8a9657ac3 Mon Sep 17 00:00:00 2001 From: shin-bot-litellm Date: Mon, 2 Feb 2026 19:17:51 +0000 Subject: [PATCH] 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 --- .../exception_mapping_utils.py | 11 +- litellm/litellm_core_utils/redact_api_keys.py | 68 ++++++++++++ litellm/llms/custom_httpx/http_handler.py | 9 +- litellm/proxy/_types.py | 4 +- .../test_redact_api_keys.py | 104 ++++++++++++++++++ 5 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 litellm/litellm_core_utils/redact_api_keys.py create mode 100644 tests/litellm/litellm_core_utils/test_redact_api_keys.py diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 3ddcae69315..d110129a53e 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -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. diff --git a/litellm/litellm_core_utils/redact_api_keys.py b/litellm/litellm_core_utils/redact_api_keys.py new file mode 100644 index 00000000000..fcfa622a84a --- /dev/null +++ b/litellm/litellm_core_utils/redact_api_keys.py @@ -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- (must be before generic sk- pattern) + (re.compile(r"(sk-ant-)[A-Za-z0-9_-]{20,}"), r"\g<1>****"), + # OpenAI-style keys: sk- + # 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 + (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= or api_key= or apikey= + (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 diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index ac9dd5998e2..6808e650d77 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -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: diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 045d2fd5f14..85e205391ce 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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 diff --git a/tests/litellm/litellm_core_utils/test_redact_api_keys.py b/tests/litellm/litellm_core_utils/test_redact_api_keys.py new file mode 100644 index 00000000000..fba1503b4ef --- /dev/null +++ b/tests/litellm/litellm_core_utils/test_redact_api_keys.py @@ -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