Skip to content

fix(proxy): carry proxy identity on the renderer WebSocket bridge - #3648

Merged
kojiwakayama merged 2 commits into
mainfrom
fix/proxy-guard-signed-bridge
Aug 12, 2026
Merged

kojiwakayama merged 2 commits into
mainfrom
fix/proxy-guard-signed-bridge

Conversation

@kojiwakayama

Copy link
Copy Markdown
Contributor

The hole

createProxyGuard (src/server/runtime-handler/project-runtime-context.ts:477) rejects the platform's own proxy → renderer WebSocket bridge (src/proxy/main.ts:272-322).

Mechanism. The bridge terminates the browser socket and opens a second socket to the shared renderer with new WebSocket(targetUrl). That API cannot set request headers, so the bridge put the tenant identity in the query string:

targetUrl.searchParams.set("x-project-slug", projectSlug || "");
targetUrl.searchParams.set("x-environment", scope);

The renderer deliberately does not read tenant identity from a WebSocket query — wsSlugOverride was pinned to undefined in #3290 because those params are browser-controlled. So the hop arrives with no x-project-slug and no x-token, the bridge target host (veryfront-server) carries no slug, and createProxyGuard answers 502 before any handler runs.

Locally this never shows up: the combined dev server runs the renderer with PROXY_MODE=0, and createProxyGuard returns undefined when !isProxyMode.

It is firing in production right now

Loki, veryfront-production, last 24h — the guard warn and the proxy's matching failure, one pair per reconnect:

{"level":"warn","service":"server","message":"x-project-slug header is required in proxy mode",
 "component":"runtime-handler","context":{"pathname":"/_ws","host":"veryfront-server","forwardedHost":null}}

{"level":"error","service":"proxy","message":"[WebSocket] Server connection error",
 "context":{"projectSlug":"support-agent-agodnc","environment":"preview",
 "targetUrl":"ws://veryfront-server/_ws?x-project-slug=support-agent-agodnc&x-environment=preview",
 "error":"NetworkError: failed to connect to WebSocket: Invalid status code: 502"}}

sum(count_over_time(... [1h])) holds between 1,000 and 5,000 rejections per hour for the entire window — never zero.

User-visible symptom: preview HMR / live-reload is dead on *.preview.veryfront.com. The browser's socket to the proxy succeeds (101), the upstream hop 502s, the client is closed 1011 "Server connection error", and the HMR script reconnects on backoff — roughly once a second, forever. Silently retried, never surfaced: no error names the guard, the header, or the proxy.

The fix

The proxy already holds the identity the guard demands. Its x-token is a project-scoped bearer it minted from its own API client credentials (VERYFRONT_PROXY_API_CLIENT_ID/SECRET), and it attaches that plus the resolved project/environment/branch identity to every other forwarded request via createProxyContextHeaders. The bridge hop was the only request that dropped it, because of a transport limitation.

So the hop now carries that same header set, over a WebSocketStream-backed client that can present headers:

  • buildRendererBridgeRequest builds the hop with createProxyContextHeaders(req.headers, context) and deletes the browser-supplied x-project-slug / x-environment query params rather than overwriting them. Identity travels only in proxy-owned headers.
  • UpstreamWebSocket (src/proxy/websocket-client.ts) presents headers on the handshake behind a WebSocket-shaped surface (readyState/send/close/onopen/onmessage/onerror/onclose with real DOM events), so the bridge wiring in main.ts is otherwise unchanged. It fails loudly if WebSocketStream is unavailable instead of falling back to a headerless socket, which would silently reproduce this bug. --unstable-net is already baked into the compiled proxy binary (scripts/build/compile-binary.ts:104).

No security control is weakened. createProxyGuard is not touched: no route is exempted, no header presence is trusted, no new credential is invented. The caller now holds the credential the gate always demanded. A prefix/path-keyed exemption was never on the table for exactly the reason recorded in #3641.

Existing shared identity: what was looked for

Before inventing a credential I checked what the proxy and renderer actually share. CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY is public-key-only on both sides — neither can mint with it. VERYFRONT_PROXY_ROUTING_INVALIDATION_SECRET is proxy-only (veryfront-server-proxy-secret; the renderer's envFrom is veryfront-server-secret, disjoint). There is no shared symmetric secret, and there does not need to be: the proxy's x-token is the existing, API-minted identity, and it now rides the hop.

Tests

TDD. The red test failed with the exact production message:

proxy renderer WebSocket bridge identity ... is admitted by the renderer proxy guard
error: AssertionError: Values are not equal: the platform's own preview HMR bridge is answered 502 by the renderer
    [Diff] Actual / Expected
-   "x-project-slug header is required in proxy mode"
+   null

The identity tests build the hop with the production builder and hand it to the production guard (prepareProjectRequest), not to a hand-written header list. Adversarial coverage — all three must stay rejected:

  • a look-alike hop that names itself only in the query string (?x-project-slug=…&x-environment=…, no headers) → still x-project-slug header is required in proxy mode
  • a bridge-shaped hop with no upstream token → still x-token header is required in proxy mode
  • a bridge hop at a boundary that is not operator-trusted → still project, environment, and branch identity headers require an operator-authenticated proxy boundary
  • and: a browser that puts x-project-slug=victim-project in the /_ws query still resolves as its own tenant, because the bridge deletes those params

Transport tests run a real Deno.serve + Deno.upgradeWebSocket renderer stand-in and assert the headers arrive on the wire, including the full browser-derived set (cookie/origin/user-agent riding along), plus the 502-rejection path and the no-silent-fallback guarantee.

src/proxy/           51 passed (497 steps) | 0 failed
runtime-handler +
handlers/preview     28 passed (450 steps) | 0 failed
deno lint / fmt / check  clean
module boundaries    exit 0 (debt -1)

No existing test was weakened or deleted.

Risk

The transport swap is the substantive risk: WebSocketStream is Deno-unstable and this path carries all preview HMR. It is compiled in, verified end-to-end against a real Deno WS server here, and the adapter keeps the bridge's event wiring identical. Worth watching after deploy: [WebSocket] Server connection error should go to zero and [WebSocket] Server connected, bridge established should appear for preview projects.

The proxy terminates a browser WebSocket and opens a second socket to the
shared renderer. That second hop was built with `new WebSocket(url)`, which
cannot set request headers, so the tenant identity was moved into the query
string. The renderer deliberately refuses to read identity from a WebSocket
query (browser-controlled), and its `createProxyGuard` answers the headerless
hop 502 "x-project-slug header is required in proxy mode".

This is firing in production continuously: 1000-5000 rejections/hour over the
last 24h, one per HMR reconnect attempt. Preview live-reload never connects;
the client retries every second forever and no user-visible error names the
cause.

The proxy already holds the identity the guard demands -- the project `x-token`
it minted from its own API client credentials, plus the resolved project,
environment and branch identity it attaches to every other forwarded request.
The fix carries that same header set on the bridge hop via
`createProxyContextHeaders`, using a `WebSocketStream`-backed client that can
present headers. The guard is unchanged: nothing is exempted, no credential is
invented, and identity still cannot come from a path, a query param or anything
a client chooses.

- `buildRendererBridgeRequest` builds the hop and deletes the browser-supplied
  `x-project-slug` / `x-environment` query params instead of overwriting them.
- `UpstreamWebSocket` presents headers on the handshake behind a
  `WebSocket`-shaped surface, so the bridge wiring is untouched. It fails loudly
  rather than falling back to a headerless socket.
- Tests assert the hop through the real guard, and that the guard still rejects
  a look-alike hop that names itself only in the query, a hop with no upstream
  token, and a hop arriving at an untrusted boundary.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

@kojiwakayama, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

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: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4feb06e8-2baf-4341-90b4-cc89ca8ab2d6

📥 Commits

Reviewing files that changed from the base of the PR and between 870bcde and 482e613.

📒 Files selected for processing (5)
  • src/proxy/main.ts
  • src/proxy/websocket-bridge-identity.test.ts
  • src/proxy/websocket-bridge.ts
  • src/proxy/websocket-client.test.ts
  • src/proxy/websocket-client.ts

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

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9290cb074c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/proxy/websocket-client.test.ts Outdated
The renderer stand-in bound a fixed port 45913. Unit test modules run under
--parallel, so a second module (or a developer/CI host already holding that
port) makes Deno.serve throw AddrInUse before any assertion runs:

  SECOND BIND THREW: AddrInUse Address already in use (os error 48)

Bind with port 0 and read the assigned port off server.addr, matching the
other proxy tests (server-resolver.test.ts, token-manager.test.ts). The
bridge target base now derives from the same address instead of the constant.

Four concurrent runs of the module pass; src/proxy/ --parallel is 51 passed
(498 steps) | 0 failed.
@kojiwakayama

Copy link
Copy Markdown
Contributor Author

CI status: 1 blocking failure, and it is not this PR

coverage shard 7/8 (and the two aggregators it feeds, coverage gate and tests (unit)) fails on:

cli/commands/skills/handler.test.ts:52
error: AssertionError: Values are not equal.
    [Diff] Actual / Expected
-   Download https://registry.npmjs.org/yaml\n

Line 52 is assertEquals(result.stderr, ""). The test spawns the CLI as a subprocess (Deno.Command(Deno.execPath(), ["run", "-A", cli/main.ts, "skills", "info", …])) and asserts its stderr is empty. What landed in stderr is Deno's own npm download progress line for npm:yaml — i.e. cli/main.ts's module graph was not fully present in DENO_DIR when the shard ran. That is a cache-warmth artifact, not a product defect.

Why it is not this PR. The diff is five files, all under src/proxy/. Nothing in it can make a separately-spawned CLI subprocess fetch an npm package. The test passes locally against this branch head with a warm cache: 1 passed (11 steps) | 0 failed.

Why re-running has not cleared it, and probably will not. Shard 7/8 passed on this same branch at 9290cb074 (run 31624206618), restoring cache key veryfront-deno-v2-…-fed0b9cc07bad23f. The follow-up commit added and removed no test file, so shard membership is unchanged, and the same key is restored — yet it now fails. Worse, across the original run and the --failed re-run the failure grew from 2 failed steps to 3, so more of the CLI graph is going cold over time rather than recovering. The "Save complete Deno dependency cache" step is skipped when the shard fails, so a failing shard can never repair the cache it depends on.

This looks like shared-cache degradation that will hit other PRs on the same stack, not something to paper over here.

Deliberately not fixed here. The obvious local patch — filtering Deno progress lines out of stderr before the assertion — weakens an unrelated CLI test from inside a proxy security fix. Flagging instead of silently loosening it.

Everything else is green: ci (lint/format/typecheck), tests (integration / bun / node / binary e2e / rsc browser e2e / npm install smoke / sentry runtime packages), tests-proxy-binary, CodeQL, and coverage shards 1–6 and 8.

Earlier in the run set, seven checks failed purely on Failed to download checksums manifest for v2.7.7 (HTTP 503) / curl (56) Connection died … (HTTP 000) from the Deno setup action. Those were infrastructure and all cleared on re-run.

@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 5467b48 Aug 12, 2026
81 of 87 checks passed
@kojiwakayama
kojiwakayama deleted the fix/proxy-guard-signed-bridge branch August 12, 2026 19:34
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