diff --git a/litellm/litellm_core_utils/sensitive_data_masker.py b/litellm/litellm_core_utils/sensitive_data_masker.py index abbd28f117a..d974bd329bc 100644 --- a/litellm/litellm_core_utils/sensitive_data_masker.py +++ b/litellm/litellm_core_utils/sensitive_data_masker.py @@ -1,7 +1,7 @@ import re from collections.abc import Mapping from typing import Any, Dict, List, Optional, Set -from urllib.parse import urlsplit, urlunsplit +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER @@ -212,6 +212,34 @@ def mask_url_credentials(value: Any) -> Any: return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) +def mask_url_query_values(value: Any) -> Any: + """Redact the values of every query parameter in a URL while keeping the + parameter names, so a secret passed as a query param (e.g. + ``https://host/v1?api_key=secret``) is not returned verbatim. The query + keys can be arbitrary (``apiKey``, ``subscription-key``), so all values are + masked rather than only the credential-looking ones. + + Non-URL strings, and URLs without a query string, are returned unchanged. + """ + if not isinstance(value, str): + return value + try: + parts = urlsplit(value) + except ValueError: + return value + if not parts.query: + return value + masked_query = urlencode( + [ + (key, _default_masker.mask_char * 4) + for key, _ in parse_qsl(parts.query, keep_blank_values=True) + ] + ) + return urlunsplit( + (parts.scheme, parts.netloc, parts.path, masked_query, parts.fragment) + ) + + # Usage example: """ masker = SensitiveDataMasker() diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 38ff8296279..6fb0ff0e234 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -41,6 +41,7 @@ from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.litellm_core_utils.sensitive_data_masker import ( mask_sensitive_keys, mask_url_credentials, + mask_url_query_values, ) from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.passthrough import BasePassthroughUtils @@ -2642,11 +2643,12 @@ def _mask_pass_through_endpoint_secrets( ) -> PassThroughGenericEndpoint: """Return a copy of the endpoint with forwarded credentials masked. - A pass-through endpoint carries upstream credentials in three places: the + A pass-through endpoint carries upstream credentials in several places: the forwarded ``headers``, the ``default_query_params`` (an API key is often - passed as a query param), and the ``target`` URL userinfo. Names/structure - stay visible so a read-only caller can still see what is configured; the - values are redacted. + passed as a query param), and the ``target`` URL, which can embed a secret + both in its userinfo and as a query parameter. Names/structure stay visible + so a read-only caller can still see what is configured; the values are + redacted. """ masked = endpoint.model_copy(deep=True) if masked.headers: @@ -2656,7 +2658,7 @@ def _mask_pass_through_endpoint_secrets( masked.default_query_params, set(masked.default_query_params.keys()) ) if masked.target: - masked.target = mask_url_credentials(masked.target) + masked.target = mask_url_query_values(mask_url_credentials(masked.target)) return masked 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 9ae687abb21..b72420996c8 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 @@ -209,3 +209,19 @@ def test_fully_redacting_masker_masks_short_secret_values(): assert set(masked["api_key"]) == {"*"} assert masked["password"] == "*" assert masked["port"] == 6379 + + +from litellm.litellm_core_utils.sensitive_data_masker import mask_url_query_values + + +def test_mask_url_query_values_redacts_all_query_values_keeps_names(): + """Query-param values (which may carry an upstream key under any name) are + masked while names and the rest of the URL are preserved.""" + out = mask_url_query_values("https://host.example/v1?api_key=secret123&x=v1") + assert "secret123" not in out + assert "api_key=" in out and "x=" in out # names preserved + assert out.startswith("https://host.example/v1?") + # No query string / non-URL inputs are unchanged. + assert mask_url_query_values("https://host.example/v1") == "https://host.example/v1" + assert mask_url_query_values("not-a-url") == "not-a-url" + assert mask_url_query_values(None) is None 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 49174548e38..f9972f821f7 100644 --- a/tests/test_litellm/proxy/test_readonly_admin_secret_redaction.py +++ b/tests/test_litellm/proxy/test_readonly_admin_secret_redaction.py @@ -134,10 +134,13 @@ def test_mask_pass_through_endpoint_secrets_redacts_all_credential_fields(): _mask_pass_through_endpoint_secrets, ) + target = ( + "https://user:targetpw@upstream.example.com/v1?subscription-key=tgt-qp-secret" + ) ep = _make_endpoint( headers={"Authorization": "Bearer sk-upstream-secret-token"}, default_query_params={"api_key": "qp-secret-key-value"}, - target="https://user:targetpw@upstream.example.com/v1", + target=target, ) masked = _mask_pass_through_endpoint_secrets(ep) @@ -147,11 +150,14 @@ def test_mask_pass_through_endpoint_secrets_redacts_all_credential_fields(): # default_query_params value masked, name preserved. assert "api_key" in masked.default_query_params assert "qp-secret-key-value" not in masked.default_query_params["api_key"] - # target URL userinfo password redacted. + # target URL userinfo password AND query-param secret both redacted. assert "targetpw" not in masked.target + assert "tgt-qp-secret" not in masked.target + assert "subscription-key" in masked.target # param name preserved # Original object is not mutated. assert ep.headers["Authorization"] == "Bearer sk-upstream-secret-token" assert ep.default_query_params["api_key"] == "qp-secret-key-value" + assert ep.target == target @pytest.mark.asyncio