fix(notifications): stop sending the credential in the WebSocket URL - #248
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The leak
NotificationClientput the caller's credential in the query string: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:
fc_keys101— WebSocket upgradesThe 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
WebSocketconstructor 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: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-apireads the credential only from the query string today: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:
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
buildCredentialHandshakeis extracted so the security property is testable directly. Four new tests:apiKeyIdsuppliedapi_key_idomittedNot fixed here — same defect class, different service
src/common/websocket-client.ts:149putstoken/api_keyin 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,lintclean. 258 pass / 0 fail across 25 files.🤖 Generated with Claude Code