chore(proxy): also redact secrets in pass-through target query string

A pass-through target can carry an upstream key as a query parameter
(e.g. https://host/v1?subscription-key=...), not only in URL userinfo. Add
mask_url_query_values to redact all target query-param values (keeping names)
for non-admin callers, alongside the existing userinfo redaction.
This commit is contained in:
user 2026-05-31 00:33:14 +00:00
parent 93ab57e478
commit 3b1b13fc94
No known key found for this signature in database
4 changed files with 60 additions and 8 deletions

View file

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

View file

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

View file

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

View file

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