phoenix-channels-python-client 0.3.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {phoenix_channels_python_client-0.3.0/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.3.1}/PKG-INFO +23 -3
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/README.md +22 -2
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/__init__.py +1 -1
- phoenix_channels_python_client-0.3.1/phoenix_channels_python_client/authentication.py +43 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/client.py +67 -35
- phoenix_channels_python_client-0.3.1/phoenix_channels_python_client/diagnostics.py +131 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/protocol_handler.py +6 -1
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/reconnect_controller.py +15 -12
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/supervisor.py +9 -2
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1/phoenix_channels_python_client.egg-info}/PKG-INFO +23 -3
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client.egg-info/SOURCES.txt +3 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/pyproject.toml +1 -1
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_channel_recovery.py +2 -2
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_connection.py +1 -2
- phoenix_channels_python_client-0.3.1/tests/test_security.py +316 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/LICENSE +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/MANIFEST.in +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/client_state_machine.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/client_types.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/exceptions.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/phx_messages.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/shutdown_signals.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/topic_runtime.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/topic_subscription.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/utils.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client.egg-info/requires.txt +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/setup.cfg +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_cancel_and_wait.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_client_state.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_heartbeat.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_protocol_handler.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_reconnect.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_reconnect_policy_invariants.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_reconnect_stress.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_run_forever_signals.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_shutdown_races.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_subscriptions.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_supervisor_internals.py +0 -0
- {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_topic_runtime_internals.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: phoenix-channels-python-client
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A Python client library for connecting to Phoenix Channels
|
|
5
5
|
Author: Phoenix Channels Python Client
|
|
6
6
|
License-Expression: MIT
|
|
@@ -365,7 +365,7 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
|
|
|
365
365
|
|
|
366
366
|
## Security Considerations
|
|
367
367
|
|
|
368
|
-
**API Key in URL**:
|
|
368
|
+
**API Key in URL**: Without an authentication header, this client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). Even over WSS (encrypted in transit), the API key may be logged by:
|
|
369
369
|
|
|
370
370
|
- Server access logs
|
|
371
371
|
- Reverse proxies and load balancers
|
|
@@ -376,8 +376,28 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
|
|
|
376
376
|
- Use short-lived, rotatable tokens rather than long-lived API keys
|
|
377
377
|
- Consider this limitation when evaluating this client for sensitive environments
|
|
378
378
|
|
|
379
|
-
|
|
379
|
+
Pass `additional_headers={"x-api-key": key}` or `{"x-auth-token": jwt}` to use header-only authentication on every connection and reconnect.
|
|
380
380
|
|
|
381
381
|
---
|
|
382
382
|
|
|
383
383
|
**Need help?** Open an issue on GitHub or check the [Phoenix Channels documentation](https://hexdocs.pm/phoenix/channels.html) for more information about the Phoenix Channels protocol.
|
|
384
|
+
|
|
385
|
+
### Authentication and diagnostic safety
|
|
386
|
+
|
|
387
|
+
A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
|
|
388
|
+
every connection and reconnect. Embedded API-key parameters are removed; empty
|
|
389
|
+
or conflicting authentication headers and simultaneous token-query authentication
|
|
390
|
+
are rejected before connecting. A JWT header overrides an embedded token and takes
|
|
391
|
+
precedence over an API-key header, matching the platform. Without an authentication header, legacy query authentication
|
|
392
|
+
is preserved and remains visible to servers and proxies. No fallback is attempted.
|
|
393
|
+
|
|
394
|
+
`client.logger` forwards sanitized connection records without raw arguments,
|
|
395
|
+
exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
|
|
396
|
+
the credential policy to transport errors a caller needs to retain. Caller-visible
|
|
397
|
+
errors may still contain personal data and should not be logged outside this policy.
|
|
398
|
+
|
|
399
|
+
Connection diagnostics omit dynamic strings and transport payloads, including URLs,
|
|
400
|
+
headers, frame bodies, user/topic identities, and untrusted server error details.
|
|
401
|
+
Logs retain static lifecycle descriptions, native connection states,
|
|
402
|
+
and exception types. Application data and caller-visible errors are not changed;
|
|
403
|
+
applications remain responsible for logs outside this connection-owned boundary.
|
|
@@ -334,7 +334,7 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
|
|
|
334
334
|
|
|
335
335
|
## Security Considerations
|
|
336
336
|
|
|
337
|
-
**API Key in URL**:
|
|
337
|
+
**API Key in URL**: Without an authentication header, this client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). Even over WSS (encrypted in transit), the API key may be logged by:
|
|
338
338
|
|
|
339
339
|
- Server access logs
|
|
340
340
|
- Reverse proxies and load balancers
|
|
@@ -345,8 +345,28 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
|
|
|
345
345
|
- Use short-lived, rotatable tokens rather than long-lived API keys
|
|
346
346
|
- Consider this limitation when evaluating this client for sensitive environments
|
|
347
347
|
|
|
348
|
-
|
|
348
|
+
Pass `additional_headers={"x-api-key": key}` or `{"x-auth-token": jwt}` to use header-only authentication on every connection and reconnect.
|
|
349
349
|
|
|
350
350
|
---
|
|
351
351
|
|
|
352
352
|
**Need help?** Open an issue on GitHub or check the [Phoenix Channels documentation](https://hexdocs.pm/phoenix/channels.html) for more information about the Phoenix Channels protocol.
|
|
353
|
+
|
|
354
|
+
### Authentication and diagnostic safety
|
|
355
|
+
|
|
356
|
+
A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
|
|
357
|
+
every connection and reconnect. Embedded API-key parameters are removed; empty
|
|
358
|
+
or conflicting authentication headers and simultaneous token-query authentication
|
|
359
|
+
are rejected before connecting. A JWT header overrides an embedded token and takes
|
|
360
|
+
precedence over an API-key header, matching the platform. Without an authentication header, legacy query authentication
|
|
361
|
+
is preserved and remains visible to servers and proxies. No fallback is attempted.
|
|
362
|
+
|
|
363
|
+
`client.logger` forwards sanitized connection records without raw arguments,
|
|
364
|
+
exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
|
|
365
|
+
the credential policy to transport errors a caller needs to retain. Caller-visible
|
|
366
|
+
errors may still contain personal data and should not be logged outside this policy.
|
|
367
|
+
|
|
368
|
+
Connection diagnostics omit dynamic strings and transport payloads, including URLs,
|
|
369
|
+
headers, frame bodies, user/topic identities, and untrusted server error details.
|
|
370
|
+
Logs retain static lifecycle descriptions, native connection states,
|
|
371
|
+
and exception types. Application data and caller-visible errors are not changed;
|
|
372
|
+
applications remain responsible for logs outside this connection-owned boundary.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
API_KEY_PARAMETER = "api_key"
|
|
6
|
+
TOKEN_PARAMETER = "token" # noqa: S105 -- protocol parameter name
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthenticationHeader(StrEnum):
|
|
10
|
+
API_KEY = "x-api-key"
|
|
11
|
+
TOKEN = "x-auth-token" # noqa: S105 -- protocol header name
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize_headers(headers: dict[str, str] | None) -> dict[str, str]:
|
|
15
|
+
normalized = dict(headers or {})
|
|
16
|
+
for header in AuthenticationHeader:
|
|
17
|
+
credentials = [
|
|
18
|
+
value for name, value in normalized.items() if name.casefold() == header
|
|
19
|
+
]
|
|
20
|
+
if not credentials:
|
|
21
|
+
continue
|
|
22
|
+
if (
|
|
23
|
+
any(not value.strip() for value in credentials)
|
|
24
|
+
or len(set(credentials)) != 1
|
|
25
|
+
):
|
|
26
|
+
raise ValueError(f"{header} headers must contain one nonempty credential")
|
|
27
|
+
normalized = {
|
|
28
|
+
name: value
|
|
29
|
+
for name, value in normalized.items()
|
|
30
|
+
if name.casefold() != header
|
|
31
|
+
}
|
|
32
|
+
normalized[header] = credentials[0]
|
|
33
|
+
return normalized
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def select_authentication_header(
|
|
37
|
+
headers: dict[str, str],
|
|
38
|
+
) -> AuthenticationHeader | None:
|
|
39
|
+
# The platform gives a supplied JWT precedence over an API key.
|
|
40
|
+
for header in (AuthenticationHeader.TOKEN, AuthenticationHeader.API_KEY):
|
|
41
|
+
if header in headers:
|
|
42
|
+
return header
|
|
43
|
+
return None
|
|
@@ -5,13 +5,24 @@ import logging
|
|
|
5
5
|
from collections import deque
|
|
6
6
|
from collections.abc import Awaitable, Callable
|
|
7
7
|
from types import TracebackType
|
|
8
|
-
from typing import Self
|
|
8
|
+
from typing import Self, cast
|
|
9
9
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
10
10
|
|
|
11
11
|
from websockets import ClientConnection
|
|
12
12
|
|
|
13
|
+
from phoenix_channels_python_client.authentication import (
|
|
14
|
+
API_KEY_PARAMETER,
|
|
15
|
+
TOKEN_PARAMETER,
|
|
16
|
+
AuthenticationHeader,
|
|
17
|
+
normalize_headers,
|
|
18
|
+
select_authentication_header,
|
|
19
|
+
)
|
|
13
20
|
from phoenix_channels_python_client.client_state_machine import transition_client_state
|
|
14
21
|
from phoenix_channels_python_client.client_types import ClientState, ReconnectPolicy
|
|
22
|
+
from phoenix_channels_python_client.diagnostics import (
|
|
23
|
+
ConnectionLogger,
|
|
24
|
+
diagnostic_secrets,
|
|
25
|
+
)
|
|
15
26
|
from phoenix_channels_python_client.exceptions import PHXConnectionError
|
|
16
27
|
from phoenix_channels_python_client.protocol_handler import (
|
|
17
28
|
DEFAULT_PROTOCOL_VERSION,
|
|
@@ -36,39 +47,56 @@ TopicLostCallback = Callable[[str, Exception], Awaitable[None]]
|
|
|
36
47
|
|
|
37
48
|
|
|
38
49
|
def _build_channel_socket_urls(
|
|
39
|
-
websocket_url: str,
|
|
50
|
+
websocket_url: str,
|
|
51
|
+
api_key: str,
|
|
52
|
+
vsn: str,
|
|
53
|
+
*,
|
|
54
|
+
header_auth: AuthenticationHeader | None = None,
|
|
40
55
|
) -> tuple[str, str]:
|
|
41
56
|
split_url = urlsplit(websocket_url)
|
|
42
57
|
query_params = parse_qsl(split_url.query, keep_blank_values=True)
|
|
58
|
+
if header_auth is AuthenticationHeader.API_KEY and any(
|
|
59
|
+
key.casefold() == TOKEN_PARAMETER and value for key, value in query_params
|
|
60
|
+
):
|
|
61
|
+
raise ValueError("token query authentication cannot be combined with x-api-key")
|
|
43
62
|
filtered = [
|
|
44
|
-
(key, value)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
split_url.netloc,
|
|
52
|
-
split_url.path,
|
|
53
|
-
urlencode(with_auth),
|
|
54
|
-
split_url.fragment,
|
|
63
|
+
(key, value)
|
|
64
|
+
for key, value in query_params
|
|
65
|
+
if key.casefold()
|
|
66
|
+
not in (
|
|
67
|
+
{API_KEY_PARAMETER, "vsn", TOKEN_PARAMETER}
|
|
68
|
+
if header_auth
|
|
69
|
+
else {API_KEY_PARAMETER, "vsn"}
|
|
55
70
|
)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
]
|
|
72
|
+
with_auth = [
|
|
73
|
+
*filtered,
|
|
74
|
+
*([] if header_auth else [(API_KEY_PARAMETER, api_key)]),
|
|
75
|
+
("vsn", vsn),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
def build(params: list[tuple[str, str]]) -> str:
|
|
79
|
+
return urlunsplit(
|
|
80
|
+
(
|
|
81
|
+
split_url.scheme,
|
|
82
|
+
split_url.netloc,
|
|
83
|
+
split_url.path,
|
|
84
|
+
urlencode(params),
|
|
85
|
+
split_url.fragment,
|
|
86
|
+
)
|
|
69
87
|
)
|
|
88
|
+
|
|
89
|
+
return build(with_auth), build(
|
|
90
|
+
[
|
|
91
|
+
(
|
|
92
|
+
key,
|
|
93
|
+
"***"
|
|
94
|
+
if key.casefold() in {API_KEY_PARAMETER, TOKEN_PARAMETER}
|
|
95
|
+
else value,
|
|
96
|
+
)
|
|
97
|
+
for key, value in with_auth
|
|
98
|
+
]
|
|
70
99
|
)
|
|
71
|
-
return connect_url, redacted_url
|
|
72
100
|
|
|
73
101
|
|
|
74
102
|
class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerMixin):
|
|
@@ -92,7 +120,10 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
92
120
|
on_topic_lost: TopicLostCallback | None = None,
|
|
93
121
|
additional_headers: dict[str, str] | None = None,
|
|
94
122
|
) -> None:
|
|
95
|
-
self.
|
|
123
|
+
self.additional_headers = normalize_headers(additional_headers)
|
|
124
|
+
self.logger = ConnectionLogger(
|
|
125
|
+
logger, diagnostic_secrets(websocket_url, api_key, self.additional_headers)
|
|
126
|
+
)
|
|
96
127
|
|
|
97
128
|
if heartbeat_interval_s is not None and heartbeat_interval_s <= 0:
|
|
98
129
|
raise ValueError("heartbeat_interval_s must be > 0 or None to disable")
|
|
@@ -106,21 +137,18 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
106
137
|
if callback_drain_timeout_s <= 0:
|
|
107
138
|
raise ValueError("callback_drain_timeout_s must be > 0")
|
|
108
139
|
|
|
109
|
-
self._protocol_handler = PHXProtocolHandler(
|
|
140
|
+
self._protocol_handler = PHXProtocolHandler(
|
|
141
|
+
protocol_version, diagnostic_logger=self.logger
|
|
142
|
+
)
|
|
110
143
|
connect_url, redacted_url = _build_channel_socket_urls(
|
|
111
144
|
websocket_url=websocket_url,
|
|
112
145
|
api_key=api_key,
|
|
113
146
|
vsn=self._protocol_handler.protocol_version.vsn,
|
|
147
|
+
header_auth=select_authentication_header(self.additional_headers),
|
|
114
148
|
)
|
|
115
149
|
self.channel_socket_url = connect_url
|
|
116
150
|
self.channel_socket_url_redacted = redacted_url
|
|
117
151
|
|
|
118
|
-
# Extra WebSocket handshake headers, re-sent on every (re)connect. Lets a
|
|
119
|
-
# caller send the API key as an ``x-api-key`` header instead of (or
|
|
120
|
-
# alongside) the URL query, so a trusted proxy can inject/replace it
|
|
121
|
-
# in-header and the credential stays out of URL/proxy logs.
|
|
122
|
-
self.additional_headers = dict(additional_headers or {})
|
|
123
|
-
|
|
124
152
|
self.auto_reconnect = auto_reconnect
|
|
125
153
|
self.reconnect_policy = reconnect_policy or ReconnectPolicy()
|
|
126
154
|
self.join_timeout_s = join_timeout_s
|
|
@@ -152,6 +180,10 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
|
|
|
152
180
|
self._lifecycle_tasks: set[asyncio.Task[None]] = set()
|
|
153
181
|
self._forced_close_pending = False
|
|
154
182
|
|
|
183
|
+
def redact_diagnostic(self, text: str) -> str:
|
|
184
|
+
"""Sanitize untrusted transport error text using this connection's policy."""
|
|
185
|
+
return cast(ConnectionLogger, self.logger).redact(text)
|
|
186
|
+
|
|
155
187
|
@property
|
|
156
188
|
def _active_shutdown(self) -> asyncio.Task[None] | None:
|
|
157
189
|
task = self._shutdown_task
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from urllib.parse import parse_qsl, quote, quote_plus, urlsplit
|
|
7
|
+
|
|
8
|
+
from websockets.protocol import State
|
|
9
|
+
|
|
10
|
+
from phoenix_channels_python_client.authentication import (
|
|
11
|
+
API_KEY_PARAMETER,
|
|
12
|
+
TOKEN_PARAMETER,
|
|
13
|
+
AuthenticationHeader,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
REDACTED = "[REDACTED]"
|
|
17
|
+
PERCENT_ESCAPE = re.compile(r"%[0-9a-fA-F]{2}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
AUTHENTICATION_NAMES = frozenset(
|
|
21
|
+
{
|
|
22
|
+
API_KEY_PARAMETER,
|
|
23
|
+
TOKEN_PARAMETER,
|
|
24
|
+
*AuthenticationHeader,
|
|
25
|
+
"x-token",
|
|
26
|
+
"authorization",
|
|
27
|
+
"cookie",
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
AUTHENTICATION_LABEL = re.compile(
|
|
31
|
+
r"(?i)((?:"
|
|
32
|
+
+ "|".join(
|
|
33
|
+
re.escape(name) for name in sorted(AUTHENTICATION_NAMES, key=len, reverse=True)
|
|
34
|
+
)
|
|
35
|
+
+ r")[\"\']?\s*[:=]\s*[\"\']?(?:(?:Bearer|Basic)\s+)?)([^\s&\"\']+)"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConnectionLogger(logging.Logger):
|
|
40
|
+
"""Forward only sanitized records; raw transport objects never reach handlers."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, target: logging.Logger, secrets: set[str]) -> None:
|
|
43
|
+
super().__init__(target.name)
|
|
44
|
+
self.target = target
|
|
45
|
+
self.parent = target
|
|
46
|
+
variants = sorted(
|
|
47
|
+
{
|
|
48
|
+
encoded
|
|
49
|
+
for secret in secrets
|
|
50
|
+
if secret
|
|
51
|
+
for encoded in (
|
|
52
|
+
secret,
|
|
53
|
+
quote(secret, safe=""),
|
|
54
|
+
quote(secret),
|
|
55
|
+
quote(secret, safe=";/?:@&=+$,#-_.!~*'()"),
|
|
56
|
+
quote_plus(secret, safe=""),
|
|
57
|
+
json.dumps(secret)[1:-1],
|
|
58
|
+
)
|
|
59
|
+
},
|
|
60
|
+
key=len,
|
|
61
|
+
reverse=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
self.patterns = [
|
|
65
|
+
re.compile(
|
|
66
|
+
PERCENT_ESCAPE.sub(lambda match: f"(?i:{match[0]})", re.escape(secret))
|
|
67
|
+
)
|
|
68
|
+
for secret in variants
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
def isEnabledFor(self, level: int) -> bool: # noqa: N802 -- logging API
|
|
72
|
+
# Connection loggers are intentionally absent from the global registry,
|
|
73
|
+
# so its cache invalidation cannot track their effective level.
|
|
74
|
+
return (
|
|
75
|
+
not self.disabled
|
|
76
|
+
and not self.target.disabled
|
|
77
|
+
and self.manager.disable < level
|
|
78
|
+
and level >= self.getEffectiveLevel()
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def redact(self, text: str) -> str:
|
|
82
|
+
for pattern in self.patterns:
|
|
83
|
+
text = pattern.sub(REDACTED, text)
|
|
84
|
+
return AUTHENTICATION_LABEL.sub(lambda match: match[1] + REDACTED, text)
|
|
85
|
+
|
|
86
|
+
def handle(self, record: logging.LogRecord) -> None:
|
|
87
|
+
if self.target.disabled:
|
|
88
|
+
return
|
|
89
|
+
message = str(record.msg)
|
|
90
|
+
if (
|
|
91
|
+
record.msg == "= connection is %s"
|
|
92
|
+
and isinstance(record.args, tuple)
|
|
93
|
+
and len(record.args) == 1
|
|
94
|
+
and isinstance(record.args[0], str)
|
|
95
|
+
and record.args[0] in State.__members__
|
|
96
|
+
):
|
|
97
|
+
message = record.msg % record.args
|
|
98
|
+
if record.exc_info and record.exc_info[0]:
|
|
99
|
+
message += " [" + record.exc_info[0].__name__ + "]"
|
|
100
|
+
safe_record = logging.LogRecord(
|
|
101
|
+
record.name,
|
|
102
|
+
record.levelno,
|
|
103
|
+
record.pathname,
|
|
104
|
+
record.lineno,
|
|
105
|
+
self.redact(message),
|
|
106
|
+
(),
|
|
107
|
+
None,
|
|
108
|
+
record.funcName,
|
|
109
|
+
)
|
|
110
|
+
filtered = self.target.filter(safe_record)
|
|
111
|
+
if filtered:
|
|
112
|
+
if isinstance(filtered, logging.LogRecord):
|
|
113
|
+
safe_record = filtered
|
|
114
|
+
super().handle(safe_record)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def diagnostic_secrets(url: str, api_key: str, headers: dict[str, str]) -> set[str]:
|
|
118
|
+
"""Collect connection credentials without changing authentication bytes."""
|
|
119
|
+
return {
|
|
120
|
+
api_key,
|
|
121
|
+
*(
|
|
122
|
+
value
|
|
123
|
+
for name, value in parse_qsl(urlsplit(url).query)
|
|
124
|
+
if name.casefold() in AUTHENTICATION_NAMES
|
|
125
|
+
),
|
|
126
|
+
*(
|
|
127
|
+
value
|
|
128
|
+
for name, value in headers.items()
|
|
129
|
+
if name.casefold() in AUTHENTICATION_NAMES
|
|
130
|
+
),
|
|
131
|
+
}
|
|
@@ -9,6 +9,7 @@ from typing import Any, NamedTuple
|
|
|
9
9
|
|
|
10
10
|
from websockets import ClientConnection
|
|
11
11
|
|
|
12
|
+
from phoenix_channels_python_client.diagnostics import ConnectionLogger
|
|
12
13
|
from phoenix_channels_python_client.phx_messages import (
|
|
13
14
|
PHOENIX_TOPIC,
|
|
14
15
|
ChannelMessage,
|
|
@@ -110,6 +111,8 @@ class PHXProtocolHandler:
|
|
|
110
111
|
self,
|
|
111
112
|
protocol_version: PhoenixChannelsProtocolVersion
|
|
112
113
|
| str = DEFAULT_PROTOCOL_VERSION,
|
|
114
|
+
*,
|
|
115
|
+
diagnostic_logger: logging.Logger | None = None,
|
|
113
116
|
) -> None:
|
|
114
117
|
# Non-v2 values select v1 for compatibility with the original client.
|
|
115
118
|
self.protocol_version = (
|
|
@@ -117,7 +120,9 @@ class PHXProtocolHandler:
|
|
|
117
120
|
if protocol_version == PhoenixChannelsProtocolVersion.V2
|
|
118
121
|
else PhoenixChannelsProtocolVersion.V1
|
|
119
122
|
)
|
|
120
|
-
self.logger =
|
|
123
|
+
self.logger = diagnostic_logger or ConnectionLogger(
|
|
124
|
+
logger.getChild("ProtocolHandler"), set()
|
|
125
|
+
)
|
|
121
126
|
self.logger.debug(
|
|
122
127
|
"Initialized PHXProtocolHandler for protocol version %s",
|
|
123
128
|
self.protocol_version,
|
|
@@ -15,10 +15,13 @@ from phoenix_channels_python_client.client_types import (
|
|
|
15
15
|
)
|
|
16
16
|
from phoenix_channels_python_client.exceptions import PHXConnectionError
|
|
17
17
|
|
|
18
|
-
logger = logging.getLogger(__name__)
|
|
19
|
-
|
|
20
18
|
|
|
21
19
|
class ReconnectControllerMixin:
|
|
20
|
+
logger: logging.Logger
|
|
21
|
+
|
|
22
|
+
def redact_diagnostic(self, text: str) -> str:
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
22
25
|
reconnect_policy: ReconnectPolicy
|
|
23
26
|
_rapid_disconnects: deque[float]
|
|
24
27
|
|
|
@@ -34,7 +37,7 @@ class ReconnectControllerMixin:
|
|
|
34
37
|
if connection_uptime_s < self.reconnect_policy.rapid_disconnect_uptime_s:
|
|
35
38
|
self._rapid_disconnects.append(now)
|
|
36
39
|
self._prune_rapid_disconnects(now)
|
|
37
|
-
logger.debug(
|
|
40
|
+
self.logger.debug(
|
|
38
41
|
"Recorded disconnect. uptime_s=%s rapid_count=%s rapid_window_s=%s",
|
|
39
42
|
connection_uptime_s,
|
|
40
43
|
len(self._rapid_disconnects),
|
|
@@ -50,7 +53,7 @@ class ReconnectControllerMixin:
|
|
|
50
53
|
threshold = self.reconnect_policy.rapid_suppress_disconnect_count
|
|
51
54
|
should_suppress = threshold > 0 and len(self._rapid_disconnects) >= threshold
|
|
52
55
|
if should_suppress:
|
|
53
|
-
logger.debug(
|
|
56
|
+
self.logger.debug(
|
|
54
57
|
"Reconnect suppression threshold met. rapid_count=%s threshold=%s",
|
|
55
58
|
len(self._rapid_disconnects),
|
|
56
59
|
threshold,
|
|
@@ -115,7 +118,7 @@ class ReconnectControllerMixin:
|
|
|
115
118
|
min_jittered = delay * low_ratio
|
|
116
119
|
max_jittered = delay * high_ratio
|
|
117
120
|
computed = self._random_between(min_jittered, max_jittered)
|
|
118
|
-
logger.debug(
|
|
121
|
+
self.logger.debug(
|
|
119
122
|
"Computed reconnect delay with hold-down jitter. attempt=%s "
|
|
120
123
|
"rapid_count=%s base_delay_s=%s min_delay_s=%s computed_delay_s=%s",
|
|
121
124
|
attempt,
|
|
@@ -134,7 +137,7 @@ class ReconnectControllerMixin:
|
|
|
134
137
|
return computed
|
|
135
138
|
|
|
136
139
|
computed = self._equal_jitter(delay)
|
|
137
|
-
logger.debug(
|
|
140
|
+
self.logger.debug(
|
|
138
141
|
"Computed reconnect delay with equal jitter. attempt=%s rapid_count=%s "
|
|
139
142
|
"base_delay_s=%s min_delay_s=%s computed_delay_s=%s",
|
|
140
143
|
attempt,
|
|
@@ -170,7 +173,7 @@ class ReconnectControllerMixin:
|
|
|
170
173
|
) -> ReconnectDecision:
|
|
171
174
|
if close_code is None:
|
|
172
175
|
decision = ReconnectDecision(should_reconnect=True)
|
|
173
|
-
logger.debug(
|
|
176
|
+
self.logger.debug(
|
|
174
177
|
"Disconnect classification result. close_code=%s close_reason=%s "
|
|
175
178
|
"should_reconnect=%s",
|
|
176
179
|
close_code,
|
|
@@ -189,7 +192,7 @@ class ReconnectControllerMixin:
|
|
|
189
192
|
and not self.reconnect_policy.reconnect_on_normal_close
|
|
190
193
|
):
|
|
191
194
|
decision = ReconnectDecision(should_reconnect=False)
|
|
192
|
-
logger.debug(
|
|
195
|
+
self.logger.debug(
|
|
193
196
|
"Disconnect classification result. close_code=%s close_reason=%s "
|
|
194
197
|
"should_reconnect=%s",
|
|
195
198
|
close_code,
|
|
@@ -215,7 +218,7 @@ class ReconnectControllerMixin:
|
|
|
215
218
|
f"{CloseCode.POLICY_VIOLATION} ({reason})"
|
|
216
219
|
),
|
|
217
220
|
)
|
|
218
|
-
logger.debug(
|
|
221
|
+
self.logger.debug(
|
|
219
222
|
"Disconnect classification result. close_code=%s close_reason=%s "
|
|
220
223
|
"should_reconnect=%s terminal=%s",
|
|
221
224
|
close_code,
|
|
@@ -237,7 +240,7 @@ class ReconnectControllerMixin:
|
|
|
237
240
|
min_delay_s=self.reconnect_policy.service_restart_min_delay_s,
|
|
238
241
|
max_delay_s=self.reconnect_policy.service_restart_max_delay_s,
|
|
239
242
|
)
|
|
240
|
-
logger.debug(
|
|
243
|
+
self.logger.debug(
|
|
241
244
|
"Disconnect classification result. close_code=%s close_reason=%s "
|
|
242
245
|
"should_reconnect=%s min_delay_s=%s max_delay_s=%s",
|
|
243
246
|
close_code,
|
|
@@ -261,7 +264,7 @@ class ReconnectControllerMixin:
|
|
|
261
264
|
min_delay_s=self.reconnect_policy.try_again_later_min_delay_s,
|
|
262
265
|
max_delay_s=self.reconnect_policy.try_again_later_max_delay_s,
|
|
263
266
|
)
|
|
264
|
-
logger.debug(
|
|
267
|
+
self.logger.debug(
|
|
265
268
|
"Disconnect classification result. close_code=%s close_reason=%s "
|
|
266
269
|
"should_reconnect=%s min_delay_s=%s max_delay_s=%s",
|
|
267
270
|
close_code,
|
|
@@ -280,7 +283,7 @@ class ReconnectControllerMixin:
|
|
|
280
283
|
return decision
|
|
281
284
|
|
|
282
285
|
decision = ReconnectDecision(should_reconnect=True)
|
|
283
|
-
logger.debug(
|
|
286
|
+
self.logger.debug(
|
|
284
287
|
"Disconnect classification result. close_code=%s close_reason=%s "
|
|
285
288
|
"should_reconnect=%s",
|
|
286
289
|
close_code,
|
|
@@ -52,6 +52,8 @@ class _CloseDetails(NamedTuple):
|
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
class _SupervisorRuntimeDeps(Protocol):
|
|
55
|
+
def redact_diagnostic(self, text: str) -> str: ...
|
|
56
|
+
|
|
55
57
|
def _generate_ref(self) -> str: ...
|
|
56
58
|
async def _rejoin_topics(self, generation: int) -> None: ...
|
|
57
59
|
def _fail_pending_joins(self, error: Exception) -> None: ...
|
|
@@ -243,14 +245,19 @@ class SupervisorMixin:
|
|
|
243
245
|
|
|
244
246
|
async def _connect(self) -> ClientConnection:
|
|
245
247
|
return await connect(
|
|
246
|
-
self.channel_socket_url,
|
|
248
|
+
self.channel_socket_url,
|
|
249
|
+
additional_headers=self.additional_headers,
|
|
250
|
+
logger=self.logger,
|
|
247
251
|
)
|
|
248
252
|
|
|
249
253
|
def _retry_after_connect_failure(self, exc: Exception) -> bool:
|
|
250
254
|
if not self.auto_reconnect:
|
|
251
255
|
self._settle_initial_connection(
|
|
252
256
|
PHXConnectionError(
|
|
253
|
-
|
|
257
|
+
self._deps.redact_diagnostic(
|
|
258
|
+
"Failed to connect to "
|
|
259
|
+
f"{self.channel_socket_url_redacted}: {exc}"
|
|
260
|
+
)
|
|
254
261
|
)
|
|
255
262
|
)
|
|
256
263
|
self.logger.error("Connection failed and auto_reconnect=False: %s", exc)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: phoenix-channels-python-client
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A Python client library for connecting to Phoenix Channels
|
|
5
5
|
Author: Phoenix Channels Python Client
|
|
6
6
|
License-Expression: MIT
|
|
@@ -365,7 +365,7 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
|
|
|
365
365
|
|
|
366
366
|
## Security Considerations
|
|
367
367
|
|
|
368
|
-
**API Key in URL**:
|
|
368
|
+
**API Key in URL**: Without an authentication header, this client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). Even over WSS (encrypted in transit), the API key may be logged by:
|
|
369
369
|
|
|
370
370
|
- Server access logs
|
|
371
371
|
- Reverse proxies and load balancers
|
|
@@ -376,8 +376,28 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
|
|
|
376
376
|
- Use short-lived, rotatable tokens rather than long-lived API keys
|
|
377
377
|
- Consider this limitation when evaluating this client for sensitive environments
|
|
378
378
|
|
|
379
|
-
|
|
379
|
+
Pass `additional_headers={"x-api-key": key}` or `{"x-auth-token": jwt}` to use header-only authentication on every connection and reconnect.
|
|
380
380
|
|
|
381
381
|
---
|
|
382
382
|
|
|
383
383
|
**Need help?** Open an issue on GitHub or check the [Phoenix Channels documentation](https://hexdocs.pm/phoenix/channels.html) for more information about the Phoenix Channels protocol.
|
|
384
|
+
|
|
385
|
+
### Authentication and diagnostic safety
|
|
386
|
+
|
|
387
|
+
A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
|
|
388
|
+
every connection and reconnect. Embedded API-key parameters are removed; empty
|
|
389
|
+
or conflicting authentication headers and simultaneous token-query authentication
|
|
390
|
+
are rejected before connecting. A JWT header overrides an embedded token and takes
|
|
391
|
+
precedence over an API-key header, matching the platform. Without an authentication header, legacy query authentication
|
|
392
|
+
is preserved and remains visible to servers and proxies. No fallback is attempted.
|
|
393
|
+
|
|
394
|
+
`client.logger` forwards sanitized connection records without raw arguments,
|
|
395
|
+
exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
|
|
396
|
+
the credential policy to transport errors a caller needs to retain. Caller-visible
|
|
397
|
+
errors may still contain personal data and should not be logged outside this policy.
|
|
398
|
+
|
|
399
|
+
Connection diagnostics omit dynamic strings and transport payloads, including URLs,
|
|
400
|
+
headers, frame bodies, user/topic identities, and untrusted server error details.
|
|
401
|
+
Logs retain static lifecycle descriptions, native connection states,
|
|
402
|
+
and exception types. Application data and caller-visible errors are not changed;
|
|
403
|
+
applications remain responsible for logs outside this connection-owned boundary.
|
|
@@ -3,9 +3,11 @@ MANIFEST.in
|
|
|
3
3
|
README.md
|
|
4
4
|
pyproject.toml
|
|
5
5
|
phoenix_channels_python_client/__init__.py
|
|
6
|
+
phoenix_channels_python_client/authentication.py
|
|
6
7
|
phoenix_channels_python_client/client.py
|
|
7
8
|
phoenix_channels_python_client/client_state_machine.py
|
|
8
9
|
phoenix_channels_python_client/client_types.py
|
|
10
|
+
phoenix_channels_python_client/diagnostics.py
|
|
9
11
|
phoenix_channels_python_client/exceptions.py
|
|
10
12
|
phoenix_channels_python_client/phx_messages.py
|
|
11
13
|
phoenix_channels_python_client/protocol_handler.py
|
|
@@ -30,6 +32,7 @@ tests/test_reconnect.py
|
|
|
30
32
|
tests/test_reconnect_policy_invariants.py
|
|
31
33
|
tests/test_reconnect_stress.py
|
|
32
34
|
tests/test_run_forever_signals.py
|
|
35
|
+
tests/test_security.py
|
|
33
36
|
tests/test_shutdown_races.py
|
|
34
37
|
tests/test_subscriptions.py
|
|
35
38
|
tests/test_supervisor_internals.py
|
|
@@ -90,7 +90,7 @@ async def test_a_crashed_channel_rejoins_on_the_live_socket(
|
|
|
90
90
|
assert (await expect_message(received)).event == EVENT
|
|
91
91
|
assert reconnects.count == 0
|
|
92
92
|
|
|
93
|
-
assert
|
|
93
|
+
assert "Recovered channel for topic %s" in caplog.text
|
|
94
94
|
|
|
95
95
|
|
|
96
96
|
@each_protocol
|
|
@@ -155,7 +155,7 @@ async def test_a_rejected_channel_rejoin_loses_the_topic(
|
|
|
155
155
|
lost.only_error()
|
|
156
156
|
assert TOPIC not in client.get_current_subscriptions()
|
|
157
157
|
assert joins(phoenix_server) == REJOINED_ONCE
|
|
158
|
-
assert
|
|
158
|
+
assert "Lost topic %s" in caplog.text
|
|
159
159
|
|
|
160
160
|
|
|
161
161
|
@each_protocol
|
|
@@ -155,8 +155,7 @@ async def test_additional_headers_are_sent_on_the_ws_handshake(
|
|
|
155
155
|
assert server_ws.request is not None
|
|
156
156
|
assert server_ws.request.headers["x-api-key"] == headers["x-api-key"]
|
|
157
157
|
|
|
158
|
-
|
|
159
|
-
assert f"api_key={API_KEY}" in client.channel_socket_url
|
|
158
|
+
assert "api_key=" not in client.channel_socket_url
|
|
160
159
|
|
|
161
160
|
|
|
162
161
|
async def test_entering_fails_when_the_server_is_unreachable(
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from http import HTTPStatus
|
|
7
|
+
from logging.handlers import BufferingHandler
|
|
8
|
+
from urllib.parse import parse_qsl, quote, urlsplit
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from websockets.asyncio.server import ServerConnection, serve
|
|
12
|
+
from websockets.http11 import Request, Response
|
|
13
|
+
|
|
14
|
+
from phoenix_channels_python_client import PHXChannelsClient
|
|
15
|
+
from phoenix_channels_python_client.client_types import ReconnectPolicy
|
|
16
|
+
from phoenix_channels_python_client.diagnostics import ConnectionLogger
|
|
17
|
+
from phoenix_channels_python_client.exceptions import PHXConnectionError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestConnectionSecurity:
|
|
21
|
+
@pytest.mark.parametrize(
|
|
22
|
+
"headers",
|
|
23
|
+
[
|
|
24
|
+
{"X-API-Key": ""},
|
|
25
|
+
{"x-api-key": " "},
|
|
26
|
+
{"x-api-key": "one", "X-Api-Key": "two"},
|
|
27
|
+
{"x-auth-token": ""},
|
|
28
|
+
{"X-Auth-Token": " "},
|
|
29
|
+
{"x-auth-token": "one", "X-Auth-Token": "two"},
|
|
30
|
+
],
|
|
31
|
+
)
|
|
32
|
+
def test_invalid_headers_are_rejected_before_network(
|
|
33
|
+
self, headers: dict[str, str]
|
|
34
|
+
) -> None:
|
|
35
|
+
with pytest.raises(ValueError, match="nonempty credential"):
|
|
36
|
+
PHXChannelsClient(
|
|
37
|
+
"ws://127.0.0.1:1/socket", "query-secret", additional_headers=headers
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def test_mixed_authentication_is_rejected(self) -> None:
|
|
41
|
+
with pytest.raises(ValueError, match="token query authentication"):
|
|
42
|
+
PHXChannelsClient(
|
|
43
|
+
"ws://127.0.0.1:1/socket?token=jwt",
|
|
44
|
+
"query-secret",
|
|
45
|
+
additional_headers={"x-api-key": "header-secret"},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def test_diagnostic_levels_follow_live_configuration(self) -> None:
|
|
49
|
+
target = logging.Logger("synthetic-target", logging.INFO) # noqa: LOG001 -- isolated target avoids global state
|
|
50
|
+
logger = ConnectionLogger(target, {"synthetic-credential"})
|
|
51
|
+
collector = BufferingHandler(100)
|
|
52
|
+
logger.addHandler(collector)
|
|
53
|
+
logger.debug("initially suppressed")
|
|
54
|
+
target.setLevel(logging.DEBUG)
|
|
55
|
+
logger.debug("inherited debug")
|
|
56
|
+
target.setLevel(logging.WARNING)
|
|
57
|
+
logger.debug("inherited suppression")
|
|
58
|
+
logger.setLevel(logging.DEBUG)
|
|
59
|
+
logger.debug("local debug")
|
|
60
|
+
logger.setLevel(logging.WARNING)
|
|
61
|
+
logger.debug("local suppression")
|
|
62
|
+
logger.setLevel(logging.DEBUG)
|
|
63
|
+
previous_disable = logging.root.manager.disable
|
|
64
|
+
try:
|
|
65
|
+
logging.disable(logging.CRITICAL)
|
|
66
|
+
logger.debug("globally suppressed")
|
|
67
|
+
logging.disable(logging.NOTSET)
|
|
68
|
+
logger.debug("globally restored")
|
|
69
|
+
finally:
|
|
70
|
+
logging.disable(previous_disable)
|
|
71
|
+
assert [record.getMessage() for record in collector.buffer] == [
|
|
72
|
+
"inherited debug",
|
|
73
|
+
"local debug",
|
|
74
|
+
"globally restored",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
def test_connections_keep_independent_diagnostic_policies(self) -> None:
|
|
78
|
+
first = PHXChannelsClient("ws://127.0.0.1:1/socket", "first-credential")
|
|
79
|
+
second = PHXChannelsClient("ws://127.0.0.1:1/socket", "second-credential")
|
|
80
|
+
assert first.redact_diagnostic("first-credential") == "[REDACTED]"
|
|
81
|
+
assert second.redact_diagnostic("first-credential") == "first-credential"
|
|
82
|
+
assert second.redact_diagnostic("second-credential") == "[REDACTED]"
|
|
83
|
+
assert first.redact_diagnostic("correlation%2fother") == "correlation%2fother"
|
|
84
|
+
|
|
85
|
+
@pytest.mark.parametrize("policy", ["disabled", "filtered"])
|
|
86
|
+
def test_connection_records_respect_registered_logger_policy(
|
|
87
|
+
self, policy: str
|
|
88
|
+
) -> None:
|
|
89
|
+
client = PHXChannelsClient("ws://127.0.0.1:1/socket", "synthetic-key")
|
|
90
|
+
target = logging.getLogger("phoenix_channels_python_client.client")
|
|
91
|
+
collector = BufferingHandler(100)
|
|
92
|
+
client.logger.addHandler(collector)
|
|
93
|
+
client.logger.setLevel(logging.WARNING)
|
|
94
|
+
previous_disabled = target.disabled
|
|
95
|
+
|
|
96
|
+
def reject(_record: logging.LogRecord) -> bool:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
target.disabled = policy == "disabled"
|
|
101
|
+
if policy == "filtered":
|
|
102
|
+
target.addFilter(reject)
|
|
103
|
+
client.logger.warning("suppressed connection record")
|
|
104
|
+
assert collector.buffer == []
|
|
105
|
+
|
|
106
|
+
target.disabled = False
|
|
107
|
+
target.removeFilter(reject)
|
|
108
|
+
client.logger.warning("enabled connection record")
|
|
109
|
+
assert [record.getMessage() for record in collector.buffer] == [
|
|
110
|
+
"enabled connection record"
|
|
111
|
+
]
|
|
112
|
+
finally:
|
|
113
|
+
target.disabled = previous_disabled
|
|
114
|
+
target.removeFilter(reject)
|
|
115
|
+
|
|
116
|
+
@pytest.mark.parametrize(
|
|
117
|
+
"header_names",
|
|
118
|
+
[
|
|
119
|
+
("X-Api-Key",),
|
|
120
|
+
("x-api-key", "X-API-KEY"),
|
|
121
|
+
("X-Auth-Token",),
|
|
122
|
+
("x-auth-token", "X-AUTH-TOKEN"),
|
|
123
|
+
("x-api-key", "x-auth-token"),
|
|
124
|
+
],
|
|
125
|
+
)
|
|
126
|
+
async def test_real_reconnect_uses_only_header_and_safe_raw_records(
|
|
127
|
+
self, caplog: pytest.LogCaptureFixture, header_names: tuple[str, ...]
|
|
128
|
+
) -> None:
|
|
129
|
+
secret = "synthetic/private+ credential?x=1&z=#[]" # noqa: S105 -- synthetic test credential
|
|
130
|
+
positional = "different-positional-secret"
|
|
131
|
+
requests = []
|
|
132
|
+
reconnected = asyncio.Event()
|
|
133
|
+
|
|
134
|
+
async def peer(connection: ServerConnection) -> None:
|
|
135
|
+
requests.append(connection.request)
|
|
136
|
+
if len(requests) == 1:
|
|
137
|
+
await connection.close(code=1012, reason=secret)
|
|
138
|
+
else:
|
|
139
|
+
reconnected.set()
|
|
140
|
+
await connection.wait_closed()
|
|
141
|
+
|
|
142
|
+
caplog.set_level(logging.DEBUG, logger="phoenix_channels_python_client.client")
|
|
143
|
+
async with serve(peer, "127.0.0.1", 0) as server:
|
|
144
|
+
port = server.sockets[0].getsockname()[1]
|
|
145
|
+
token = (
|
|
146
|
+
"legacy-jwt"
|
|
147
|
+
if any(name.casefold() == "x-auth-token" for name in header_names)
|
|
148
|
+
else ""
|
|
149
|
+
)
|
|
150
|
+
url = f"ws://127.0.0.1:{port}/socket?API%5FKEY=embedded-secret&api_key=embedded-secret&aPi_KeY=embedded-secret&token={token}&keep=one&keep=&vsn=old"
|
|
151
|
+
async with PHXChannelsClient(
|
|
152
|
+
url,
|
|
153
|
+
positional,
|
|
154
|
+
additional_headers={
|
|
155
|
+
**dict.fromkeys(header_names, secret),
|
|
156
|
+
"x-token": "synthetic-additional-token",
|
|
157
|
+
},
|
|
158
|
+
heartbeat_interval_s=None,
|
|
159
|
+
reconnect_policy=ReconnectPolicy(
|
|
160
|
+
base_delay_s=0.01,
|
|
161
|
+
service_restart_min_delay_s=0.01,
|
|
162
|
+
service_restart_max_delay_s=0.01,
|
|
163
|
+
rapid_first_min_delay_s=0.01,
|
|
164
|
+
),
|
|
165
|
+
) as client:
|
|
166
|
+
collector = BufferingHandler(1000)
|
|
167
|
+
client.logger.addHandler(collector)
|
|
168
|
+
await asyncio.wait_for(reconnected.wait(), 5)
|
|
169
|
+
client.logger.error(
|
|
170
|
+
"authorization: Bearer %s", "unconfigured-bearer-value"
|
|
171
|
+
)
|
|
172
|
+
client.logger.error(
|
|
173
|
+
'"authorization": "Basic %s"', "unconfigured-basic-value"
|
|
174
|
+
)
|
|
175
|
+
client.logger.error(
|
|
176
|
+
"untrusted %s", secret, extra={"websocket": {"secret": secret}}
|
|
177
|
+
)
|
|
178
|
+
assert client.redact_diagnostic(quote(secret, safe="")) == "[REDACTED]"
|
|
179
|
+
assert client.redact_diagnostic(quote(secret)) == "[REDACTED]"
|
|
180
|
+
assert (
|
|
181
|
+
client.redact_diagnostic(
|
|
182
|
+
quote(secret, safe="").replace("%2F", "%2f")
|
|
183
|
+
)
|
|
184
|
+
== "[REDACTED]"
|
|
185
|
+
)
|
|
186
|
+
try:
|
|
187
|
+
raise RuntimeError(secret) # noqa: TRY301 -- exercise exception records
|
|
188
|
+
except RuntimeError:
|
|
189
|
+
client.logger.exception(
|
|
190
|
+
"refused credential", extra={"websocket": secret}
|
|
191
|
+
)
|
|
192
|
+
for request in requests:
|
|
193
|
+
assert parse_qsl(urlsplit(request.path).query, keep_blank_values=True) == [
|
|
194
|
+
("keep", "one"),
|
|
195
|
+
("keep", ""),
|
|
196
|
+
("vsn", "2.0.0"),
|
|
197
|
+
]
|
|
198
|
+
for name in header_names:
|
|
199
|
+
assert request.headers[name] == secret
|
|
200
|
+
assert request.headers["x-token"] == "synthetic-additional-token"
|
|
201
|
+
records = [
|
|
202
|
+
record
|
|
203
|
+
for record in caplog.records
|
|
204
|
+
if record.name == "phoenix_channels_python_client.client"
|
|
205
|
+
]
|
|
206
|
+
assert records
|
|
207
|
+
assert collector.buffer
|
|
208
|
+
for record in [*records, *collector.buffer]:
|
|
209
|
+
assert record.args == ()
|
|
210
|
+
assert record.exc_info is None
|
|
211
|
+
assert not hasattr(record, "websocket")
|
|
212
|
+
for credential in (
|
|
213
|
+
secret,
|
|
214
|
+
positional,
|
|
215
|
+
"embedded-secret",
|
|
216
|
+
"synthetic-additional-token",
|
|
217
|
+
"unconfigured-bearer-value",
|
|
218
|
+
"unconfigured-basic-value",
|
|
219
|
+
quote(secret, safe=""),
|
|
220
|
+
):
|
|
221
|
+
assert credential not in repr(record.__dict__)
|
|
222
|
+
assert any("OPEN" in record.getMessage() for record in records)
|
|
223
|
+
|
|
224
|
+
async def test_refused_jwt_handshake_keeps_token_out_of_query_and_records(
|
|
225
|
+
self, caplog: pytest.LogCaptureFixture
|
|
226
|
+
) -> None:
|
|
227
|
+
secret = "synthetic.jwt/private+ token" # noqa: S105 -- synthetic credential
|
|
228
|
+
requests = []
|
|
229
|
+
|
|
230
|
+
def refuse(connection: ServerConnection, request: Request) -> Response:
|
|
231
|
+
requests.append(request)
|
|
232
|
+
return connection.respond(HTTPStatus.UNAUTHORIZED, secret)
|
|
233
|
+
|
|
234
|
+
async def peer(connection: ServerConnection) -> None:
|
|
235
|
+
await connection.wait_closed()
|
|
236
|
+
|
|
237
|
+
caplog.set_level(logging.DEBUG, logger="phoenix_channels_python_client.client")
|
|
238
|
+
async with serve(peer, "127.0.0.1", 0, process_request=refuse) as server:
|
|
239
|
+
port = server.sockets[0].getsockname()[1]
|
|
240
|
+
client = PHXChannelsClient(
|
|
241
|
+
f"ws://127.0.0.1:{port}/socket?token=old-jwt&api_key=old-key",
|
|
242
|
+
secret,
|
|
243
|
+
additional_headers={"X-Auth-Token": secret},
|
|
244
|
+
auto_reconnect=False,
|
|
245
|
+
)
|
|
246
|
+
with pytest.raises(PHXConnectionError, match="401"):
|
|
247
|
+
await client.__aenter__()
|
|
248
|
+
[request] = requests
|
|
249
|
+
assert parse_qsl(urlsplit(request.path).query) == [("vsn", "2.0.0")]
|
|
250
|
+
assert request.headers["x-auth-token"] == secret
|
|
251
|
+
records = [
|
|
252
|
+
record
|
|
253
|
+
for record in caplog.records
|
|
254
|
+
if record.name == "phoenix_channels_python_client.client"
|
|
255
|
+
]
|
|
256
|
+
assert records
|
|
257
|
+
assert all(secret not in repr(record.__dict__) for record in records)
|
|
258
|
+
|
|
259
|
+
async def test_transport_and_protocol_records_omit_personal_data(
|
|
260
|
+
self, caplog: pytest.LogCaptureFixture
|
|
261
|
+
) -> None:
|
|
262
|
+
identity = "synthetic-person@example.invalid"
|
|
263
|
+
session = "synthetic-cookie-session"
|
|
264
|
+
delivered = asyncio.Event()
|
|
265
|
+
|
|
266
|
+
def response(
|
|
267
|
+
_connection: ServerConnection, _request: Request, reply: Response
|
|
268
|
+
) -> Response:
|
|
269
|
+
reply.headers["Set-Cookie"] = "session=" + session + "; HttpOnly"
|
|
270
|
+
return reply
|
|
271
|
+
|
|
272
|
+
async def peer(connection: ServerConnection) -> None:
|
|
273
|
+
await connection.send(
|
|
274
|
+
json.dumps([None, None, identity, "event", {"email": identity}])
|
|
275
|
+
)
|
|
276
|
+
delivered.set()
|
|
277
|
+
await connection.wait_closed()
|
|
278
|
+
|
|
279
|
+
caplog.set_level(logging.DEBUG, logger="phoenix_channels_python_client.client")
|
|
280
|
+
async with serve(peer, "127.0.0.1", 0, process_response=response) as server:
|
|
281
|
+
port = server.sockets[0].getsockname()[1]
|
|
282
|
+
client = PHXChannelsClient(
|
|
283
|
+
f"ws://127.0.0.1:{port}/socket?email={identity}",
|
|
284
|
+
"synthetic-key",
|
|
285
|
+
additional_headers={
|
|
286
|
+
"x-api-key": "synthetic-key",
|
|
287
|
+
"Cookie": "session=" + session,
|
|
288
|
+
},
|
|
289
|
+
auto_reconnect=False,
|
|
290
|
+
heartbeat_interval_s=None,
|
|
291
|
+
)
|
|
292
|
+
collector = BufferingHandler(1000)
|
|
293
|
+
client.logger.addHandler(collector)
|
|
294
|
+
async with client:
|
|
295
|
+
await asyncio.wait_for(delivered.wait(), 5)
|
|
296
|
+
await asyncio.sleep(0.01)
|
|
297
|
+
try:
|
|
298
|
+
raise RuntimeError(identity) # noqa: TRY301 -- exercise exception records
|
|
299
|
+
except RuntimeError:
|
|
300
|
+
client.logger.exception("Connection callback failed: %s", session)
|
|
301
|
+
client.logger.warning(
|
|
302
|
+
"Untrusted numeric identity: %d %.2f %s",
|
|
303
|
+
15555550123,
|
|
304
|
+
15555550123.0,
|
|
305
|
+
identity,
|
|
306
|
+
)
|
|
307
|
+
assert any(
|
|
308
|
+
"Parsing raw message:" in record.getMessage() for record in collector.buffer
|
|
309
|
+
)
|
|
310
|
+
assert any("OPEN" in record.getMessage() for record in collector.buffer)
|
|
311
|
+
for record in collector.buffer:
|
|
312
|
+
assert identity not in repr(record.__dict__)
|
|
313
|
+
assert session not in repr(record.__dict__)
|
|
314
|
+
assert "15555550123" not in repr(record.__dict__)
|
|
315
|
+
assert record.args == ()
|
|
316
|
+
assert record.exc_info is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|