Skip to content

fix(security): realtime dropped the org header and put the API key in the URL - #23

Open
yakimoto wants to merge 2 commits into
mainfrom
fix/realtime-org-header-and-token-leak
Open

fix(security): realtime dropped the org header and put the API key in the URL#23
yakimoto wants to merge 2 commits into
mainfrom
fix/realtime-org-header-and-token-leak

Conversation

@yakimoto

@yakimoto yakimoto commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Two security findings against wave/realtime.py, both confirmed against origin/main before touching anything.

1 — Multi-tenant isolation bypass

WaveClient._build_headers() stamps the tenant on every request:

if self.organization_id:
    headers["X-Organization-Id"] = self.organization_id

RealtimeAPI did not go through it. It took client.api_key and built its own header dict:

def __init__(self, client: WaveClient, url: str = _DEFAULT_WS):
    self._api_key = client.api_key          # organization_id never captured

def _headers(self) -> dict[str, str]:
    return {"Authorization": f"Bearer {self._api_key}", "content-type": "application/json"}

So publish(), presence(), history() and the WebSocket upgrade all ran unscoped, while all 35 other API modules carried the org. Realtime is the surface where this matters most — a channel subscription is exactly the thing that should be tenant-bounded.

2 — API key in the WebSocket URL query string

# Browser/SDK clients can't set headers on the WS upgrade → key travels as a query param (wss).
url = f"{ws_base.rstrip('/')}/v1/connect?channel={channel}&access_token={api_key}"

The comment is true of browsers. It is not true of this code path: this is a Python client using websocket-client, whose create_connection(url, header=[...]) sets arbitrary headers on the upgrade. The justification was imported from a constraint that does not bind here, and a credential in a URL is recorded by every hop that logs a request line — proxies, edge access logs, Referer, shell history.

The key now travels as Authorization: Bearer on the upgrade. token_in_query=True re-enables the legacy parameter for a deployment that cannot read the header — off by default, and it does not disable the header when switched on.

3 — Query-parameter and path injection (found while reading)

channel and as_ were interpolated raw. A channel named stream:abc&as=victim injected an as parameter on the upgrade; one containing / escaped its REST path segment. Now urlencode for the query and quote(safe=":") for the path, keeping WAVE's stream:abc shape literal.

Verification

tests/test_realtime_auth.py                      9 passed
full suite                                       17 passed, 2 failed, 1 skipped

The 9 new tests assert the org header on REST and in the upgrade header list, that api_key is absent from the connect URL by default, that the legacy param is opt-in, and that stream:abc&as=victim cannot inject as.

Both suite failures are pre-existing and unrelated. test_sdk_exports asserts 33 APIs (there are 36) and version 2.0.0 (it is 2.1.0). Proved rather than assumed — running that file against a clean git archive of origin/main gives the same two failures:

$ git archive origin/main | tar -x -C $T && pytest tests/test_sdk_exports.py -q
FAILED tests/test_sdk_exports.py::test_api_count
FAILED tests/test_sdk_exports.py::test_version
2 failed, 8 passed

Those two stale assertions are worth a separate fix; they are not this PR's to smuggle.

What is NOT established

/v1/connect does not appear anywhere in wave-realtime-edge@main, so I could not confirm server-side acceptance of the header form from code I can read. src/landing.ts documents that edge's auth as Authorization: Bearer <key> ("this edge makes zero auth decisions"), which is why header-first is the default rather than a coin flip — but no live handshake against realtime.wave.online has been run. If the gateway turns out to read only access_token, the escape hatch is RealtimeAPI(client, token_in_query=True) and the correct follow-up is to fix the server, not to re-widen the SDK.


Note

High Risk
Changes authentication and multi-tenant isolation for realtime WebSocket and REST; misconfiguration or server mismatch could break connects or leave operations unscoped until verified against production.

Overview
Realtime now aligns with the rest of the SDK on tenant scoping and credential transport.

RealtimeAPI copies organization_id from WaveClient and sends X-Organization-Id on REST (publish, presence, history) and on the WebSocket upgrade. WebSocket auth defaults to Authorization: Bearer via upgrade headers instead of ?access_token= in the connect URL; token_in_query=True keeps the legacy query param as an opt-in without dropping the header.

Connect and REST URLs use urlencode for query params and _channel_path (quote with : safe) so channel/as_ values cannot inject query parameters or break path segments.

Adds tests/test_realtime_auth.py with stubbed websocket-client to lock in org headers, header-based auth, legacy query opt-in, and encoding behavior.

Reviewed by Cursor Bugbot for commit 7f2dd10. Configure here.


Summary by cubic

Secures the realtime client by scoping all calls to the organization and moving the API key from the URL into the Authorization header. Also encodes channel and query values to block injection.

  • Bug Fixes

    • Carry X-Organization-Id on realtime REST calls and the WebSocket upgrade.
    • Send the API key as Authorization: Bearer on the WS upgrade (no key in the URL). token_in_query=True keeps the legacy ?access_token= if needed.
    • Encode channel and as_ (query and REST path) to prevent parameter injection and path escaping.
    • Style-only: sorted imports in tests per ruff (no behavior change).
  • Migration

    • No changes for most users.
    • If your gateway only accepts access_token in the URL, initialize RealtimeAPI(client, token_in_query=True) until the server supports headers.

Written for commit 2f91999. Summary will update on new commits.

Review in cubic

Note

Fix realtime authentication to send API key in Authorization header instead of URL

  • RealtimeChannel now sends the API key as a Bearer token in the Authorization header and includes X-Organization-Id on WebSocket upgrades; the API key is no longer placed in the connect URL by default.
  • A new token_in_query flag on RealtimeAPI and RealtimeChannel restores the legacy behavior of appending access_token to the query string for clients that require it.
  • REST methods (publish, presence, history) and WebSocket URLs now use percent-encoded channel names via the new _channel_path helper, preventing path/query injection.
  • Behavioral Change: existing integrations relying on the API key appearing in the WebSocket URL will need to opt in via token_in_query=True.

Macroscope summarized 2f91999.

… the URL

Two defects in `wave/realtime.py`, both flagged against sdk-python.

1. Multi-tenant isolation bypass. `WaveClient._build_headers()` stamps `X-Organization-Id` when
   `organization_id` is configured, and every other SDK module goes through it. `RealtimeAPI` took
   only `client.api_key` and built its own header dict that omitted the org, so `publish()`,
   `presence()`, `history()` and the WebSocket upgrade all ran unscoped — the one surface where a
   channel subscription is exactly the thing that should be tenant-bounded.

2. API key in the WebSocket URL query string. The inline comment justified it as "Browser/SDK
   clients can't set headers on the WS upgrade". True of browsers; not true here. This is a Python
   client using websocket-client, whose `create_connection(url, header=[...])` sets arbitrary
   upgrade headers. The justification was imported from a constraint that does not bind this code
   path, and a credential in a URL is recorded by every hop that logs a request line.

   The key now travels as `Authorization: Bearer` on the upgrade. `token_in_query=True` re-enables
   the legacy parameter for a deployment that cannot read the header — off by default, documented
   as insecure, and it does not disable the header when on.

3. Found while reading: `channel` and `as_` were interpolated raw into the query string, and
   `channel` raw into the REST path. A channel containing `&` injected a query parameter; one
   containing `/` left its path segment. Now urlencoded (`urlencode`, and `quote(safe=":")` for the
   path, keeping WAVE's `stream:abc` shape literal).

Verified: 9 new tests in tests/test_realtime_auth.py, all passing — org header present on REST and
on the upgrade header list, api_key absent from the connect URL by default, legacy param opt-in,
and a channel named `stream:abc&as=victim` cannot inject `as`.

Full suite: 17 passed, 2 failed, 1 skipped. Both failures are pre-existing on origin/main and
unrelated — test_sdk_exports asserts 33 APIs (there are 36) and version 2.0.0 (it is 2.1.0). Proved
by running that file against a clean `git archive` of origin/main: same 2 failures, same reasons.

Not established: `/v1/connect` does not appear anywhere in wave-realtime-edge@main, so I could not
confirm server-side acceptance of the header form from code. `src/landing.ts` documents the edge's
auth as `Authorization: Bearer <key>`, which is why header-first is the default rather than a
guess — but a live handshake against realtime.wave.online has not been run. Filing that separately.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 52 minutes

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5346f055-adb2-4d72-925f-55a7b8d265ff

📥 Commits

Reviewing files that changed from the base of the PR and between 0e79241 and 2f91999.

📒 Files selected for processing (2)
  • tests/test_realtime_auth.py
  • wave/realtime.py

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Open in Devin Review

Comment thread wave/realtime.py
Comment on lines +142 to +149
def __init__(self, client: WaveClient, url: str = _DEFAULT_WS, token_in_query: bool = False):
self._api_key = client.api_key
# Multi-tenant isolation: WaveClient stamps X-Organization-Id on every other surface, so
# realtime carries it too — on the REST calls and on the WS upgrade.
self._organization_id = client.organization_id
self._ws_base = url.rstrip("/")
self._http_base = _http_origin(self._ws_base)
self._token_in_query = token_in_query

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Release notes not updated for a user-facing behaviour change

The change alters how credentials and tenant scoping are sent for realtime connections and adds a new opt-in setting (token_in_query at wave/realtime.py:142) without adding an entry to the Unreleased section of CHANGELOG.md, so users get no notice of the behaviour change.
Impact: Users upgrading the SDK will not see that realtime authentication changed or that a new opt-in option exists.

Repository rule requiring changelog updates

AGENTS.md states: "Conventional Commit titles; update CHANGELOG.md (Unreleased) for user-facing changes." The Unreleased section in CHANGELOG.md:7 is empty and untouched by this PR, while the PR changes realtime auth transport (header instead of query token), adds X-Organization-Id to realtime REST/WS traffic, and percent-encodes channel path segments — all user-visible.

Prompt for agents
AGENTS.md requires updating CHANGELOG.md's Unreleased section for user-facing changes. This PR changes realtime authentication (API key now sent in the Authorization header on the WS upgrade instead of the URL query), adds X-Organization-Id propagation to realtime REST and WS traffic, adds a new token_in_query opt-in on RealtimeAPI, and percent-encodes channel names in REST paths. Add appropriate Fixed/Added/Changed entries under ## [Unreleased] in CHANGELOG.md.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread wave/realtime.py
Comment on lines +80 to +90
headers = [f"Authorization: Bearer {api_key}"]
if organization_id:
headers.append(f"X-Organization-Id: {organization_id}")

if token_in_query:
# Legacy form for deployments that cannot read the upgrade header. The key lands in
# proxy logs, edge access logs, and shell history — opt in deliberately or not at all.
params["access_token"] = api_key

url = f"{ws_base.rstrip('/')}/v1/connect?{urlencode(params)}"
self._ws = websocket.create_connection(url, header=headers)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Server must accept the Authorization upgrade header for realtime

Moving the credential out of the URL relies on the realtime gateway accepting Authorization: Bearer ... on the WebSocket upgrade. If the deployed gateway only reads ?access_token=, every existing user's connect() will start failing after upgrade unless they explicitly pass token_in_query=True. Worth confirming server-side support (or gating the change behind a version) before release.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Repo isort config groups `wave.*` with the first block and third-party after (matching the existing
wave/realtime.py). Applied `ruff check --fix`; 9 tests still pass, `ruff check .` clean.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Risk: high. Left a non-blocking comment: Cursor Bugbot passed with no findings, but this PR changes realtime auth and multi-tenant scoping so it exceeds the medium approval threshold and needs human review. No eligible non-author reviewers were available to assign.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment thread wave/realtime.py
Comment on lines +38 to +44
def _channel_path(channel: str) -> str:
"""Percent-encode a channel for use as a single REST path segment.

``:`` stays literal because WAVE channel names are ``stream:abc`` shaped; everything else that
could leave the segment (``/``, ``?``, ``#``, ``&``) is encoded.
"""
return quote(channel, safe=":")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Channel encoding in REST paths changes the wire format the server sees

_channel_path percent-encodes everything except : (wave/realtime.py:38-44), so channels containing characters like /, #, or spaces now reach the server as %2F/%23 path segments where they previously produced multiple path segments or truncated URLs. Worth confirming the realtime service decodes the path segment before matching channel names, otherwise previously-working channels with unusual characters would start resolving differently.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@macroscopeapp

macroscopeapp Bot commented Aug 3, 2026

Copy link
Copy Markdown

Approvability

Verdict: Needs human review

Unable to check for correctness in 2f91999. This security fix changes how credentials are transmitted for realtime connections (from URL query to Authorization header), which is a security-sensitive authentication change that could break existing integrations if the server doesn't support header-based auth. Open review comment raises valid concern about deployment coordination.

You can customize Macroscope's approvability policy. Learn more.

@yakimoto

yakimoto commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

The "not established" note in the description is now resolved

I verified the server side. The realtime endpoint this module targets is not implemented — it returns a 501 NOT_IMPLEMENTED response for every path under it, including one I invented as a control, so it is a blanket handler rather than per-route rejection. No code in the platform reads the query-string token.

So the auth question is settled in the best possible way: there is no server behaviour this change can break. Header-first is safe to merge now, and when the service is implemented it should be built against the header from the start — a credential in a URL is recorded by every hop that logs a request line.

token_in_query=True therefore stays as documented insurance, not as a hedge against an unknown.

Related: the module has a broader problem — the default host it points at doesn't work, and RealtimeChannel.__iter__ swallows connection failures with except Exception: return, so a user gets an empty stream rather than an error. That combination is why the incorrect comment this PR removes ("clients can't set headers on the WS upgrade") survived: the code path has never executed. Tracked internally; not in scope here.

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