diff --git a/CHANGELOG.md b/CHANGELOG.md index a77a031..f49fcf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,8 @@ All notable changes to this project are documented here. The format follows characters, and caller-supplied `Host` headers. - Made bearer-header replacement case-insensitive and redact request secrets from transport, API, and decoding exception details. +- Redacted values under sensitive query parameter names from API and transport + error diagnostics. - Redacted login tokens from model representations. [Unreleased]: https://github.com/hubuum/hubuum-client-python/commits/main diff --git a/docs/advanced.md b/docs/advanced.md index a83806b..a5b9c27 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -27,8 +27,9 @@ except NotFoundError as error: ``` Exceptions retain the method, query-free URL, status, API error code, message, -and request ID where available. They never retain the bearer token or outgoing -request body. +and request ID where available. Bearer tokens, secret-bearing request headers +and bodies, and values under sensitive query parameter names are redacted from +their diagnostics. ## Complete OpenAPI operation surface diff --git a/src/hubuum_client/_transport.py b/src/hubuum_client/_transport.py index 7f2d004..c2c7c74 100644 --- a/src/hubuum_client/_transport.py +++ b/src/hubuum_client/_transport.py @@ -6,7 +6,7 @@ from collections.abc import Mapping from contextlib import suppress from typing import Any, TypeVar -from urllib.parse import unquote, urlsplit +from urllib.parse import quote_plus, unquote, urlsplit import httpx from pydantic import BaseModel, ValidationError @@ -21,6 +21,7 @@ PermissionDeniedError, RateLimitError, ) +from .options import Params T = TypeVar("T", bound=BaseModel) _REDACTED = "" @@ -138,7 +139,11 @@ def _collect_sensitive_values(value: Any, result: set[str], *, sensitive: bool = result.add(value) -def sensitive_request_values(headers: httpx.Headers, body: Any) -> set[str]: +def sensitive_request_values( + headers: httpx.Headers, + body: Any, + params: Params = None, +) -> set[str]: """Return exact secret strings that must never survive into an exception.""" result: set[str] = set() for name, value in headers.multi_items(): @@ -147,6 +152,10 @@ def sensitive_request_values(headers: httpx.Headers, body: Any) -> set[str]: scheme, separator, credential = value.partition(" ") if separator and scheme.casefold() in {"basic", "bearer"} and credential: result.add(credential) + for name, value in httpx.QueryParams(params).multi_items(): + if _sensitive_key(name) and value: + result.add(value) + result.add(quote_plus(value, safe="")) _collect_sensitive_values(body, result) return result @@ -178,7 +187,7 @@ def _response_request_secrets(response: httpx.Response) -> set[str]: body: Any = None with suppress(UnicodeDecodeError, ValueError): body = json.loads(request.content) if request.content else None - return sensitive_request_values(request.headers, body) + return sensitive_request_values(request.headers, body, request.url.params) def validation_error_reason(error: TypeError | ValueError, response: httpx.Response) -> str: diff --git a/src/hubuum_client/async_client.py b/src/hubuum_client/async_client.py index 128c039..ca818bc 100644 --- a/src/hubuum_client/async_client.py +++ b/src/hubuum_client/async_client.py @@ -233,7 +233,11 @@ async def _send_request( try: return await self._http.send(request, stream=stream) except httpx.HTTPError as error: - secrets = sensitive_request_values(request.headers, request_body) + secrets = sensitive_request_values( + request.headers, + request_body, + request.url.params, + ) raise TransportError( request.method, str(request.url.copy_with(query=None, fragment=None)), diff --git a/src/hubuum_client/client.py b/src/hubuum_client/client.py index d615c07..cf8314f 100644 --- a/src/hubuum_client/client.py +++ b/src/hubuum_client/client.py @@ -236,7 +236,11 @@ def _send_request( try: return self._http.send(request, stream=stream) except httpx.HTTPError as error: - secrets = sensitive_request_values(request.headers, request_body) + secrets = sensitive_request_values( + request.headers, + request_body, + request.url.params, + ) raise TransportError( request.method, str(request.url.copy_with(query=None, fragment=None)), diff --git a/tests/unit/test_async_client.py b/tests/unit/test_async_client.py index f95dbc4..70a6492 100644 --- a/tests/unit/test_async_client.py +++ b/tests/unit/test_async_client.py @@ -3,6 +3,7 @@ import json from collections.abc import Callable from typing import Any +from urllib.parse import quote_plus import httpx import pytest @@ -203,3 +204,38 @@ def handler(request: httpx.Request) -> httpx.Response: assert await client.config() == {} with pytest.raises(DecodeError): await client.request("GET", "/api/v1/custom-invalid-json") + + +async def test_async_query_secrets_are_redacted() -> None: + query_secret = "async /+% café secret" + encoded_query_secret = quote_plus(query_secret, safe="") + + def rejected(request: httpx.Request) -> httpx.Response: + assert request.url.params["token"] == query_secret + return httpx.Response(400, text=f"rejected {query_secret}") + + async with _client(rejected) as client: + with pytest.raises(APIError) as raised: + await client.request( + "GET", + "/api/v1/custom", + options=RequestOptions(params={"token": query_secret}), + ) + + assert query_secret not in str(raised.value) + assert query_secret not in repr(raised.value) + + def offline(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError(f"failed while sending {request.url}", request=request) + + async with _client(offline) as client: + with pytest.raises(TransportError) as raised_transport: + await client.request( + "GET", + "/api/v1/custom", + options=RequestOptions(params={"access-token": query_secret}), + ) + + assert query_secret not in str(raised_transport.value) + assert encoded_query_secret not in str(raised_transport.value) + assert query_secret not in repr(raised_transport.value) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index d44242d..1e67ce8 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -3,6 +3,7 @@ import json from collections.abc import Callable from typing import Any +from urllib.parse import quote_plus import httpx import pytest @@ -294,6 +295,43 @@ def offline(request: httpx.Request) -> httpx.Response: assert "bearer-secret" not in repr(raised_transport.value) +def test_query_secrets_are_redacted_from_api_and_transport_errors() -> None: + query_secret = "query /+% café secret" + encoded_query_secret = quote_plus(query_secret, safe="") + + def rejected(request: httpx.Request) -> httpx.Response: + assert request.url.params["api_key"] == query_secret + return httpx.Response( + 400, + json={"error": "InvalidApiKey", "message": f"rejected {query_secret}"}, + ) + + with _client(rejected) as client, pytest.raises(APIError) as raised: + client.request( + "GET", + "/api/v1/custom", + options=RequestOptions(params={"api_key": query_secret}), + ) + + assert query_secret not in str(raised.value) + assert query_secret not in repr(raised.value) + assert query_secret not in str(raised.value.response_body) + + def offline(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError(f"failed while sending {request.url}", request=request) + + with _client(offline) as client, pytest.raises(TransportError) as raised_transport: + client.request( + "GET", + "/api/v1/custom", + options=RequestOptions(params={"api-key": query_secret}), + ) + + assert query_secret not in str(raised_transport.value) + assert encoded_query_secret not in str(raised_transport.value) + assert query_secret not in repr(raised_transport.value) + + def test_configured_auth_replaces_header_case_insensitively_and_host_is_locked() -> None: def handler(request: httpx.Request) -> httpx.Response: assert request.headers.get_list("authorization") == ["Bearer configured-token"]