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.
This commit is contained in:
user 2026-05-30 20:50:41 +00:00
parent 48c6138e71
commit 041e96ae5c
No known key found for this signature in database
7 changed files with 56 additions and 8 deletions

View file

@ -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))

View file

@ -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()}

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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([]) == []

View file

@ -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