Skip to content

fix(notifications): stop sending the credential in the WebSocket URL - #248

Merged
jbiskur merged 1 commit into
mainfrom
fix/notification-credential-not-in-url
Aug 29, 2026
Merged

fix(notifications): stop sending the credential in the WebSocket URL#248
jbiskur merged 1 commit into
mainfrom
fix/notification-credential-not-in-url

Conversation

@jbiskur

@jbiskur jbiskur commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

The leak

NotificationClient put the caller's credential in the query string:

wss://tenant.api.flowcore.io/notifications?api_key=fc_…&api_key_id=…

An ingress access log records the request line, so every connection wrote a live credential to disk in plaintext. Measured in production, over two hours:

  • 300 matching nginx log lines
  • 59 distinct fc_ keys
  • all status 101 — WebSocket upgrades
  • readable by anyone with Groundcover log access

The OIDC branch has the same defect with token=. It is not currently exercised, which is luck rather than design.

Why not a header

These are WebSocket handshakes. The standard WebSocket constructor takes only a URL and subprotocols, and this SDK targets browsers as well as Node (globalThis.WebSocket ?? NodeWebSocket), so custom headers are not portable.

The credential therefore moves to Sec-WebSocket-Protocol:

["flowcore-api-key", "<key>", "<keyId>"]
["flowcore-bearer",  "<token>"]

nginx does not log request headers by default, so the credential stops being recorded.

Server dependency — and why this is still safe to merge alone

service-tenant-api reads the credential only from the query string today:

// src/server/routes/notifications/index.ts:117
.resolve(async ({ query }) => {
  const { token, api_key, api_key_id } = query

So it will refuse the subprotocol handshake until it ships support.

This client therefore probes once. If the first handshake never opens, it falls back to the legacy query transport and reconnects immediately. The fallback:

  • consumes no reconnect backoff,
  • does not error the observable (a refused probe is a result, not a fault),
  • is attempted at most once per client — after that, failures are real failures.

Behaviour against today's server is therefore unchanged. Once the server ships subprotocol support, the fallback stops firing and the leak stops with it. Merge order does not matter.

The property is asserted, not inferred

buildCredentialHandshake is extracted so the security property is testable directly. Four new tests:

Test Asserts
default transport, api key query is empty; protocols carry the credential
default transport, bearer query is empty
legacy transport the leaking shape, pinned so accidental re-defaulting is visible
no apiKeyId supplied api_key_id omitted

Not fixed here — same defect class, different service

src/common/websocket-client.ts:149 puts token/api_key in the URL for the ai-coordinator stream. It needs its own server-side change and is tracked separately.

Also required, outside this PR

The 59 keys already in those logs should be treated as exposed and rotated. This PR stops new ones being written; it does nothing about the ones already recorded.

Gates

typecheck, lint clean. 258 pass / 0 fail across 25 files.

🤖 Generated with Claude Code

NotificationClient put the caller's credential in the query string:

    wss://tenant.api.flowcore.io/notifications?api_key=fc_…&api_key_id=…

An ingress access log records the request line, so every connection wrote a
live credential to disk in plaintext. Measured in production: 300 log lines
in two hours carrying 59 DISTINCT `fc_` keys, readable by anyone with log
access. The OIDC branch has the same defect with `token=`; it is not
currently exercised, which is luck rather than design.

A header is not available here. These are WebSocket handshakes and the
standard `WebSocket` constructor takes only a URL and subprotocols, so the
credential moves to `Sec-WebSocket-Protocol` instead:

    ["flowcore-api-key", "<key>", "<keyId>"]
    ["flowcore-bearer",  "<token>"]

`buildCredentialHandshake` is extracted so the property is asserted
directly rather than inferred: on the default transport the returned query
is EMPTY.

SERVER DEPENDENCY, and why this is still safe to merge alone.
`service-tenant-api` currently reads the credential ONLY from the query
string, so it will refuse the subprotocol handshake until it ships support.
This client therefore probes ONCE: if the first handshake never opens, it
falls back to the legacy query transport and reconnects immediately. The
fallback consumes no backoff and does not error the observable, so
behaviour against today's server is unchanged. Once the server ships, the
fallback stops firing and the leak stops with it.

NOT fixed here, same defect class, different service:
`src/common/websocket-client.ts:149` puts `token`/`api_key` in the URL for
the ai-coordinator stream. It needs its own server change and is tracked
separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jbiskur
jbiskur merged commit 77cd5bb into main Aug 29, 2026
2 checks passed
@jbiskur
jbiskur deleted the fix/notification-credential-not-in-url branch August 29, 2026 15:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant