Skip to content

fix: verify the server certificate on websocket connections - #27

Merged
cigamit merged 1 commit into
ctrliq:mainfrom
blaipr:fix/ws-tls-verification
Sep 13, 2026
Merged

fix: verify the server certificate on websocket connections#27
cigamit merged 1 commit into
ctrliq:mainfrom
blaipr:fix/ws-tls-verification

Conversation

@blaipr

@blaipr blaipr commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

WSClient.connect() passed a fixed {"cert_reqs": ssl.CERT_NONE} to run_forever, so the websocket never verified the server certificate and there was no way to make it. It ignored config.assume_untrusted, which the HTTP connection reads, so a caller that had deliberately turned verification on still got an unverified socket for the event stream, carrying a session cookie or a bearer token.

WSClient now takes verify=None, resolving to not config.assume_untrusted when it is not passed, which is the same expression Page uses to build its Connection. When verifying, connect() passes no sslopt at all, so websocket-client applies its own defaults and checks both the chain and the hostname. When not verifying, it sends cert_reqs=ssl.CERT_NONE with check_hostname=False, which is what the old behaviour actually was.

Four tests added: the default follows the config in both directions, and an explicit verify= overrides it in both directions.

Worth knowing while reviewing: the CLI never reaches this code. --monitor is HTTP polling through monitor() and monitor_workflow(), so WSClient is a library entry point only. That limits the blast radius, and it is also why this went unnoticed.

Verified with black --check, flake8 and the unit suite, 359 passing.

@ciq-it-service-account

ciq-it-service-account commented Sep 12, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

cigamit pushed a commit that referenced this pull request Sep 13, 2026
Using this package as a library meant unverified TLS, silently.

`Connection.__init__` took `verify=False`, and the value that actually governs
the normal path, `config.assume_untrusted`, defaulted to `True`. So
`Page(...)` built a connection with verification off, and the constructor then
called `disable_warnings()` so nothing said as much. A library that talks to
an Ascender over HTTPS was not checking who answered.

Two defaults flip:

- `Connection(server, verify=True)`.
- `config.assume_untrusted` now defaults to `False`, and reads
  `ASCENDERKIT_ASSUME_UNTRUSTED` for the opt-out, following the same pattern as
  `ASCENDERKIT_SESSIONS` and `ASCENDERKIT_PREVENT_TEARDOWN`. There was no
  environment variable for it before, only the config dictionary.

This is a behaviour change for library consumers and is in the changelog as one.
Anyone pointed at an Ascender with a self-signed certificate sets the variable,
or passes `verify=False` explicitly, and is back where they were.

**The CLI is unaffected.** `CLI.connect()` already sets
`config.assume_untrusted = False` and only sets it `True` for
`-k` / `--conf.insecure`, so it has always verified by default. This brings the
library in line with the CLI rather than changing the CLI.

Out of scope, and worth its own change: `ascenderkit/cli/__init__.py:16` calls
`urllib3.disable_warnings(InsecureRequestWarning)` unconditionally at import, so
even a verifying connection has the warning suppressed. And `ws.py` hardcodes
`ssl.CERT_NONE` regardless of any of this, which #27 covers.

Note for whoever merges: #25 also touches this constructor, so whichever lands
second needs a rebase.

Verified with `black --check`, `flake8` and the unit suite, 355 passing.
@cigamit cigamit self-assigned this Sep 13, 2026
@cigamit cigamit added the enhancement New feature or request label Sep 13, 2026
cigamit
cigamit previously approved these changes Sep 13, 2026
`WSClient.connect()` passed a fixed `{"cert_reqs": ssl.CERT_NONE}` to
`run_forever`, so the websocket never verified the server certificate and there
was no way to make it. It ignored `config.assume_untrusted`, which the HTTP
connection reads, so a caller that had deliberately turned verification on still
got an unverified socket for the event stream, carrying a session cookie or a
bearer token.

`WSClient` now takes `verify=None`, resolving to `not config.assume_untrusted`
when it is not passed, which is the same expression `Page` uses to build its
`Connection`. When verifying, `connect()` passes no `sslopt` at all, so
websocket-client applies its own defaults and checks both the chain and the
hostname. When not verifying, it sends `cert_reqs=ssl.CERT_NONE` with
`check_hostname=False`, which is what the old behaviour actually was.

Four tests added: the default follows the config in both directions, and an
explicit `verify=` overrides it in both directions.

Worth knowing while reviewing: the CLI never reaches this code. `--monitor` is
HTTP polling through `monitor()` and `monitor_workflow()`, so `WSClient` is a
library entry point only. That limits the blast radius, and it is also why this
went unnoticed.

Verified with `black --check`, `flake8` and the unit suite, 359 passing.
blaipr added a commit to blaipr/ascender-kit that referenced this pull request Sep 13, 2026
`WSClient._should_subscribe_to_pending_job` holds `False` until a pending subscription is queued and a dict afterwards, and three places subscript it. The subscripts are safe, because the only path that reaches them tests the flag first, but the test was buried:

```python
if all([message.get('group_name') == 'jobs', message.get('status') == 'pending', message.get('unified_job_id'), self._should_subscribe_to_pending_job]):
    if bool(message.get('project_id')) == (self._should_subscribe_to_pending_job['events'] == 'project_update_events'):
        self._update_subscription(message['unified_job_id'])
```

Four unrelated conditions in an `all([...])`, one of them the guard for the line below it, and `_update_subscription` then reaching back for the attribute a second time rather than being handed it.

Three changes, none of which alters behaviour:

- The sentinel is `None` rather than `False`, annotated `dict | None`. Both are falsy and nothing compares it by identity or to `False`, so every existing check behaves the same. `None` is what "not set yet" means.
- The flag is bound to a local and tested first, with `and` instead of `all([...])`. Short-circuiting rather than eager evaluation, which is fine here since every element is a pure `.get()`.
- `_update_subscription` takes the dict as an argument instead of re-reading the attribute, so it cannot be called in a state where that attribute is unset.

Exercised the whole path directly, since the unit suite covers the callbacks but not this branch: queueing with `subscribe_to_pending_events('job_events')`, then feeding a pending-job message through `_on_message`, resubscribes with `{'jobs': ['status_changed'], 'job_events': [7]}` and clears the sentinel back to `None`.

Four diagnostics retired. Diffed the full list before and after: strict subset, nothing introduced.

Note for whoever merges: ctrliq#27 also edits this file, so whichever lands second needs a rebase.

Verified with `black --check`, `flake8` and the unit suite, 355 passing.
@blaipr
blaipr force-pushed the fix/ws-tls-verification branch from 95c07e0 to 7c2a4df Compare September 13, 2026 09:03
@cigamit
cigamit merged commit 4908c4a into ctrliq:main Sep 13, 2026
1 check passed
cigamit pushed a commit that referenced this pull request Sep 13, 2026
`WSClient._should_subscribe_to_pending_job` holds `False` until a pending subscription is queued and a dict afterwards, and three places subscript it. The subscripts are safe, because the only path that reaches them tests the flag first, but the test was buried:

```python
if all([message.get('group_name') == 'jobs', message.get('status') == 'pending', message.get('unified_job_id'), self._should_subscribe_to_pending_job]):
    if bool(message.get('project_id')) == (self._should_subscribe_to_pending_job['events'] == 'project_update_events'):
        self._update_subscription(message['unified_job_id'])
```

Four unrelated conditions in an `all([...])`, one of them the guard for the line below it, and `_update_subscription` then reaching back for the attribute a second time rather than being handed it.

Three changes, none of which alters behaviour:

- The sentinel is `None` rather than `False`, annotated `dict | None`. Both are falsy and nothing compares it by identity or to `False`, so every existing check behaves the same. `None` is what "not set yet" means.
- The flag is bound to a local and tested first, with `and` instead of `all([...])`. Short-circuiting rather than eager evaluation, which is fine here since every element is a pure `.get()`.
- `_update_subscription` takes the dict as an argument instead of re-reading the attribute, so it cannot be called in a state where that attribute is unset.

Exercised the whole path directly, since the unit suite covers the callbacks but not this branch: queueing with `subscribe_to_pending_events('job_events')`, then feeding a pending-job message through `_on_message`, resubscribes with `{'jobs': ['status_changed'], 'job_events': [7]}` and clears the sentinel back to `None`.

Four diagnostics retired. Diffed the full list before and after: strict subset, nothing introduced.

Note for whoever merges: #27 also edits this file, so whichever lands second needs a rebase.

Verified with `black --check`, `flake8` and the unit suite, 355 passing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Development

Successfully merging this pull request may close these issues.

3 participants