From 041e96ae5c56d1ace62de7ec2bbfa55e14ce5a9f Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Sat, 30 May 2026 20:50:41 +0000 Subject: [PATCH] chore(proxy): close residual config-read disclosure gaps from review - /config/field/info also blocks alert_to_webhook_url and pass_through_endpoints (Slack webhook URL + forwarded upstream auth headers), which a read-only admin could otherwise read despite the dedicated masked endpoints. - mask *_HEADERS callback vars (OTEL_HEADERS, GENERIC_LOGGER_HEADERS) for non-admins; these can carry bearer tokens but have no credential-looking name segment. - keep IPv6 brackets when redacting a connection-URL password so the value stays well-formed. --- .../sensitive_data_masker.py | 7 ++++--- litellm/proxy/common_utils/callback_utils.py | 9 +++++++- litellm/proxy/proxy_server.py | 11 ++++++++-- tests/proxy_unit_tests/test_proxy_server.py | 4 +++- .../test_sensitive_data_masker.py | 7 +++++++ .../proxy/common_utils/test_callback_utils.py | 21 +++++++++++++++++++ .../test_readonly_admin_secret_redaction.py | 5 ++++- 7 files changed, 56 insertions(+), 8 deletions(-) diff --git a/litellm/litellm_core_utils/sensitive_data_masker.py b/litellm/litellm_core_utils/sensitive_data_masker.py index f3599bde6b2..abbd28f117a 100644 --- a/litellm/litellm_core_utils/sensitive_data_masker.py +++ b/litellm/litellm_core_utils/sensitive_data_masker.py @@ -203,9 +203,10 @@ def mask_url_credentials(value: Any) -> Any: return value if parts.password is None: return value - netloc = ( - f"{parts.username or ''}:{_default_masker.mask_char * 4}@{parts.hostname or ''}" - ) + host = parts.hostname or "" + if ":" in host: # IPv6 literal needs its brackets back after urlsplit strips them + host = f"[{host}]" + netloc = f"{parts.username or ''}:{_default_masker.mask_char * 4}@{host}" if parts.port is not None: netloc += f":{parts.port}" return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) diff --git a/litellm/proxy/common_utils/callback_utils.py b/litellm/proxy/common_utils/callback_utils.py index 8d88f19c056..44ed1d9c0ae 100644 --- a/litellm/proxy/common_utils/callback_utils.py +++ b/litellm/proxy/common_utils/callback_utils.py @@ -643,7 +643,14 @@ def process_callback( env_vars_dict[_var] = environment_variables.get(_var, None) if mask_sensitive: - sensitive_keys = {k for k in env_vars_dict if _is_sensitive_callback_var(k)} + # *_HEADERS callback vars (e.g. OTEL_HEADERS, GENERIC_LOGGER_HEADERS) can + # carry bearer tokens but have no credential-looking segment, so mask + # them alongside the segment-matched sensitive vars. + sensitive_keys = { + k + for k in env_vars_dict + if _is_sensitive_callback_var(k) or k.upper().endswith("_HEADERS") + } env_vars_dict = mask_sensitive_keys(env_vars_dict, sensitive_keys) env_vars_dict = {k: mask_url_credentials(v) for k, v in env_vars_dict.items()} diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index e1493f63025..3b7e93a4087 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -14254,8 +14254,15 @@ async def update_config_general_settings( # general_settings fields that hold credentials. These must never be returned # verbatim through the read endpoint: it is reachable by PROXY_ADMIN_VIEW_ONLY, # and the master_key in particular would let a read-only caller mint a full -# admin key. -_SECRET_CONFIG_GENERAL_SETTINGS_FIELDS = {"master_key", "database_url"} +# admin key. alert_to_webhook_url carries Slack webhook URLs and +# pass_through_endpoints carries forwarded upstream auth headers, both of which +# are otherwise masked on their dedicated endpoints. +_SECRET_CONFIG_GENERAL_SETTINGS_FIELDS = { + "master_key", + "database_url", + "alert_to_webhook_url", + "pass_through_endpoints", +} @router.get( diff --git a/tests/proxy_unit_tests/test_proxy_server.py b/tests/proxy_unit_tests/test_proxy_server.py index 1a2ca9946df..e37e73f0189 100644 --- a/tests/proxy_unit_tests/test_proxy_server.py +++ b/tests/proxy_unit_tests/test_proxy_server.py @@ -2856,8 +2856,10 @@ async def test_get_config_callbacks_environment_variables(client_no_auth): assert otel_vars["OTEL_EXPORTER"] == "otlp" assert "OTEL_ENDPOINT" in otel_vars assert otel_vars["OTEL_ENDPOINT"] == "http://localhost:4317" + # OTEL_HEADERS can carry bearer tokens, so it is masked for non-admins. assert "OTEL_HEADERS" in otel_vars - assert otel_vars["OTEL_HEADERS"] == "key=value" + assert otel_vars["OTEL_HEADERS"] != "key=value" + assert "*" in otel_vars["OTEL_HEADERS"] @pytest.mark.asyncio diff --git a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py index 4110823a011..f293d252d8d 100644 --- a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py +++ b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py @@ -190,3 +190,10 @@ def test_mask_url_credentials_leaves_non_credentialed_values_untouched(): assert mask_url_credentials("plain-secret-value") == "plain-secret-value" assert mask_url_credentials(None) is None assert mask_url_credentials(1234) == 1234 + + +def test_mask_url_credentials_preserves_ipv6_brackets(): + """IPv6 hosts keep their brackets so the redacted URL stays well-formed.""" + out = mask_url_credentials("redis://:supersecretpw@[::1]:6379") + assert out == "redis://:****@[::1]:6379" + assert "supersecretpw" not in out diff --git a/tests/test_litellm/proxy/common_utils/test_callback_utils.py b/tests/test_litellm/proxy/common_utils/test_callback_utils.py index 53e52b40ee0..d101f5e8a9d 100644 --- a/tests/test_litellm/proxy/common_utils/test_callback_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_callback_utils.py @@ -142,6 +142,27 @@ def test_process_callback_masks_url_embedded_credentials(mock_get_env_vars): assert "topsecretpw" not in result["variables"]["GENERIC_LOGGER_URL"] +@patch( + "litellm.proxy.common_utils.callback_utils.CustomLogger.get_callback_env_vars", + return_value=["OTEL_HEADERS", "OTEL_ENDPOINT"], +) +def test_process_callback_masks_headers_vars(mock_get_env_vars): + """*_HEADERS callback vars can carry bearer tokens, so they are masked even + though the name has no credential-looking segment; the endpoint stays clear.""" + result = process_callback( + _callback="otel", + callback_type="success", + environment_variables={ + "OTEL_HEADERS": "Authorization=Bearer sk-otel-secret-token", + "OTEL_ENDPOINT": "http://collector.internal:4317", + }, + mask_sensitive=True, + ) + assert "sk-otel-secret-token" not in result["variables"]["OTEL_HEADERS"] + assert "*" in result["variables"]["OTEL_HEADERS"] + assert result["variables"]["OTEL_ENDPOINT"] == "http://collector.internal:4317" + + def test_normalize_callback_names_none_returns_empty_list(): assert normalize_callback_names(None) == [] assert normalize_callback_names([]) == [] diff --git a/tests/test_litellm/proxy/test_readonly_admin_secret_redaction.py b/tests/test_litellm/proxy/test_readonly_admin_secret_redaction.py index 8b433ce2f73..6216da5118d 100644 --- a/tests/test_litellm/proxy/test_readonly_admin_secret_redaction.py +++ b/tests/test_litellm/proxy/test_readonly_admin_secret_redaction.py @@ -159,7 +159,10 @@ async def test_pass_through_get_masks_headers_for_non_admin_only(): @pytest.mark.asyncio -@pytest.mark.parametrize("field_name", ["master_key", "database_url"]) +@pytest.mark.parametrize( + "field_name", + ["master_key", "database_url", "alert_to_webhook_url", "pass_through_endpoints"], +) async def test_config_field_info_blocks_secret_fields(field_name): from litellm.proxy.proxy_server import get_config_general_settings