From e14811bdbfb5ad4e89f05f23de6024267a688a90 Mon Sep 17 00:00:00 2001 From: derhornspieler <15236687+derhornspieler@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:50:26 -0400 Subject: [PATCH] fix(auth): redact the encoded form of a client secret as well as the raw one client_secret_basic sends base64 of "id:secret", and an endpoint that echoes that blob back hands over material that decodes straight to the secret. Redaction only ever compared the raw value, so the encoded form reached the caller and the telemetry intact. The redactor now takes every shape the credential went out in, and the Keycloak source declares both, so an echo of either is caught. Only client_secret_basic carries the encoded form; client_secret_post sends the secret as a plain field. --- .../llms/base_llm/auth/client_credentials.py | 17 +++++++++++- litellm/llms/base_llm/auth/token_exchange.py | 26 +++++++++++++++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/litellm/llms/base_llm/auth/client_credentials.py b/litellm/llms/base_llm/auth/client_credentials.py index d25dc86a535..ec312bc63bd 100644 --- a/litellm/llms/base_llm/auth/client_credentials.py +++ b/litellm/llms/base_llm/auth/client_credentials.py @@ -145,8 +145,23 @@ def _resolve_client_secret(config: KeycloakSource, secret_reader: SecretReader) return secret +def _wire_forms_of_secret(config: KeycloakSource, client_secret: str) -> tuple[SecretStr, ...]: + """Every shape the secret leaves this process in, so an echo of any of them is caught. + + client_secret_basic sends base64 of ``id:secret``, which decodes straight back to the secret, + so an endpoint echoing that blob hands over reversible material that a raw comparison misses. + """ + raw: Final = SecretStr(client_secret) + if config.auth_method != "client_secret_basic": + return (raw,) + encoded_pair: Final = f"{_form_encode(config.client_id)}:{_form_encode(client_secret)}" + return (raw, SecretStr(base64.b64encode(encoded_pair.encode()).decode("ascii"))) + + def _endpoint_error_message(config: KeycloakSource, response: httpx.Response, client_secret: str) -> str: - endpoint_error: Final = redact_oauth_error_body(response.status_code, response.text, SecretStr(client_secret)) + endpoint_error: Final = redact_oauth_error_body( + response.status_code, response.text, _wire_forms_of_secret(config, client_secret) + ) return ( f"keycloak token endpoint {endpoint_url_for_error_message(config.token_url)} " f"returned HTTP {endpoint_error.status_code}: {endpoint_error.redacted_body}" diff --git a/litellm/llms/base_llm/auth/token_exchange.py b/litellm/llms/base_llm/auth/token_exchange.py index de10108fd23..e0cab3f4ad2 100644 --- a/litellm/llms/base_llm/auth/token_exchange.py +++ b/litellm/llms/base_llm/auth/token_exchange.py @@ -13,7 +13,7 @@ import json import re import threading import time -from collections.abc import Callable, Coroutine, Mapping +from collections.abc import Callable, Coroutine, Mapping, Sequence from concurrent.futures import Executor, ThreadPoolExecutor from dataclasses import dataclass from math import inf @@ -116,12 +116,28 @@ def validate_token_endpoint_url(url: str) -> str | InsecureTokenUrl: return InsecureTokenUrl(host=parsed.hostname or "") -def redact_oauth_error_body(status_code: int, body_text: str, assertion: SecretStr | None = None) -> TokenEndpointError: +def redact_oauth_error_body( + status_code: int, + body_text: str, + assertion: SecretStr | Sequence[SecretStr] | None = None, +) -> TokenEndpointError: + """``assertion`` may be every form of the credential that went out on the wire. + + A grant that encodes its credential before sending it (``client_secret_basic`` base64s + ``id:secret``) can have that encoded form echoed back, and it decodes straight to the secret, + so checking only the raw value lets reversible material through. + """ rendered: Final = _redact_body_text(body_text) - return TokenEndpointError( - status_code=status_code, - redacted_body=_drop_reflected_assertion(rendered, assertion), + secrets: Final = () if assertion is None else (assertion,) if isinstance(assertion, SecretStr) else tuple(assertion) + redacted: Final = next( + ( + _REFLECTED_VALUE_MESSAGE + for secret in secrets + if _drop_reflected_assertion(rendered, secret) is _REFLECTED_VALUE_MESSAGE + ), + rendered, ) + return TokenEndpointError(status_code=status_code, redacted_body=redacted) def _drop_reflected_assertion(rendered: str, assertion: SecretStr | None) -> str: