From 9f2b59cc962726f6d09ccbd219a26fc93f3cd087 Mon Sep 17 00:00:00 2001 From: derhornspieler <15236687+derhornspieler@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:03:34 -0400 Subject: [PATCH] fix(auth): compare a short secret whole when scanning for a reflected credential The reflection scan only ever compared eight-character windows of the response against the secret, so a secret with fewer credential characters than that could never match once an endpoint echoed it percent-encoded or split up, and the verbatim check needs the raw form. Cap the run at the secret's length so a short client secret is compared whole in every wire shape. --- litellm/llms/base_llm/auth/token_exchange.py | 12 +++++++++--- .../llms/base_llm/auth/test_token_exchange.py | 13 +++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/litellm/llms/base_llm/auth/token_exchange.py b/litellm/llms/base_llm/auth/token_exchange.py index 9da2a942b9c..96e2e1a62af 100644 --- a/litellm/llms/base_llm/auth/token_exchange.py +++ b/litellm/llms/base_llm/auth/token_exchange.py @@ -169,17 +169,23 @@ def _drop_reflected_assertion(rendered: str, assertion: SecretStr | None) -> str def _shares_a_credential_run(rendered: str, compacted_secret: str) -> bool: """``unquote`` covers a credential sent form-encoded, without every caller enumerating that shape for itself: percent-escaping is reversible and applies to any field, query string - included.""" + included. + + A secret shorter than the probe run is compared whole: a window longer than the secret can + never be found inside it, which would leave a short client secret unprotected in every shape + but the verbatim one. + """ # unquote covers %XX; unquote_plus additionally covers the "+" a form-encoded body uses for a # space. Both are kept rather than only the wider one, because "+" is a base64 character and # decoding it away would lose a run that the undecoded candidate still matches on. + run: Final = min(_REFLECTION_MIN_RUN, len(compacted_secret)) compacted_candidates: Final = tuple( _CREDENTIAL_CHARS.sub("", candidate) for candidate in (rendered, unquote(rendered), unquote_plus(rendered)) ) return any( - compacted[start : start + _REFLECTION_MIN_RUN] in compacted_secret + compacted[start : start + run] in compacted_secret for compacted in compacted_candidates - for start in range(len(compacted) - _REFLECTION_MIN_RUN + 1) + for start in range(len(compacted) - run + 1) ) diff --git a/tests/test_litellm/llms/base_llm/auth/test_token_exchange.py b/tests/test_litellm/llms/base_llm/auth/test_token_exchange.py index e65d65b5d97..4cfc090a6eb 100644 --- a/tests/test_litellm/llms/base_llm/auth/test_token_exchange.py +++ b/tests/test_litellm/llms/base_llm/auth/test_token_exchange.py @@ -703,6 +703,19 @@ class TestRedactionAndCaps: assert "short-secret" not in result.redacted_body + def test_a_short_secret_echoed_in_its_wire_shape_is_dropped(self): + """Regression: the run scan only ever compared eight-character windows, so a secret + with fewer credential characters than that could never match once it came back + percent-encoded rather than verbatim, and the whole-value check needs the raw form.""" + secret = SecretStr("p@ss w0rd!") + echoed = quote(secret.get_secret_value(), safe="") + body = {"error": "invalid_client", "error_description": f"rejected {echoed}"} + + assert secret.get_secret_value() not in echoed + result = redact_oauth_error_body(400, json.dumps(body), secret) + + assert echoed not in result.redacted_body + def test_an_unrelated_body_is_not_falsely_redacted(self): """The scan must not fire on a body that merely shares short runs with the assertion.""" assertion = SecretStr("eyJhbGciOiJSUzI1NiJ9." + "Z" * 60 + ".signature")