Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 3 additions & 2 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 12 additions & 3 deletions src/hubuum_client/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,6 +21,7 @@
PermissionDeniedError,
RateLimitError,
)
from .options import Params

T = TypeVar("T", bound=BaseModel)
_REDACTED = "<redacted>"
Expand Down Expand Up @@ -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():
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion src/hubuum_client/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
6 changes: 5 additions & 1 deletion src/hubuum_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
36 changes: 36 additions & 0 deletions tests/unit/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
38 changes: 38 additions & 0 deletions tests/unit/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down