Findings
CDP live view over SSE + REST
The portal's live browser view (cookie-auth capture and browser_live_url) used
to be a raw CDP WebSocket proxy: the browser opened a socket to
/api/auth/cookie/:integration/cdp or /api/browser-session/cdp, and the server
piped frames to chromium's own CDP socket. It is now three plain HTTP endpoints
per base path — POST /attach, GET /events (SSE), POST /commands, plus
POST /detach — implemented in packages/server/src/auth/cdp-bridge.ts.
Chromium has not changed and cannot: its CDP endpoint is only reachable over a
WebSocket. That hop is still ws, entirely server-side. What went away is the
socket between the browser and us.
Why bother#
A WebSocket cannot send an Authorization header. That single limitation
shaped the whole old design. Because the handshake could not carry the portal
bearer, the client had to send it as the first message —
{"type":"auth", sessionId, cdpToken, bearer} — and the server had to hold an
un-authorized socket open (with a 5s timeout and four custom close codes,
4400/4401/4403/4408) while it waited for that frame, then decide whether to dial
chromium. Auth-in-band is a shape you carry, not a feature: a credential arriving
as message content, on a connection that already exists, with a bespoke error
channel that no HTTP client understands.
With REST, attach is just a request. It carries the bearer in the header, it
answers 401/403/201 like everything else on the server, and no chromium socket
exists until it has succeeded.
Nothing in the path has to forward an Upgrade. The deploy docs used to tell
operators to configure WebSocket proxying for two specific paths. An SSE response
is a response; any proxy that can stream one is enough. What replaces that
instruction is narrower and more common: don't buffer or compress the stream
(Cache-Control: no-transform and X-Accel-Buffering: no are sent, which nginx
honours), and keep read timeouts above the 15s keepalive comment.
The channel#
attach mints a channelId and dials chromium. It is a handle, not a
credential: every follow-up re-proves the portal session and must match the
userId that opened it, so another user's channelId reads as 404. This is the
same rule the 2026-09-02 connect-link finding arrived at — a token that stands in
for a person is the bug.
One stream owns a channel. A second GET /events on the same channel is refused
(409) rather than splitting it, and when the stream drops the channel closes:
otherwise chromium keeps screencasting into nothing. Reconnecting is a fresh
attach, which is why the client does not try to resume a stream. A channel that
attaches but never streams (client died between the two requests) is reaped after
120s.
Two things the transport change forced#
EventSource is not usable here. It cannot set request headers, so an
EventSource client would have to put the credential back in the URL — the exact
thing the old in-band auth frame existed to avoid. The portal reads the SSE
stream through fetch + body.getReader() and parses frames itself. That costs
the browser's automatic reconnect, which we did not want anyway (a reconnect
needs a new channel, see above).
One POST per input event would be absurd. A socket made mousemove cheap.
The client now coalesces commands into one POST per ~12ms and drops a mouseMoved
that a newer one supersedes; Page.screencastFrameAck rides the same batch, so
frame pacing still works. The server caps a batch at 64 commands — an unbounded
array is free memory for a caller to burn.
The header a same-origin GET does not send#
Per Fetch, a browser omits Origin on a same-origin GET — it appends the header
only for cross-origin requests and for methods other than GET/HEAD. The old
proxy never met this, because a WebSocket handshake always carries Origin.
Ported verbatim, the origin allowlist made the event stream unreachable: attach
(a POST) passed, then GET /events 403'd for having no Origin at all.
So the stream accepts a missing Origin and only rejects a present-but-wrong
one, while every POST still requires the header. That is not a hole: a
cross-origin reader (fetch, XHR, EventSource) does send Origin and is
rejected, and a tag-based load that omits it cannot set Authorization, so it
401s before any channel lookup.
Testing it#
cdp-bridge.test.ts covers the transport's rules against a fake socket.
cdp-bridge.chromium.test.ts (opt-in via TEST_CHROMIUM=1) runs the bridge
against a real headless chromium and asserts real jpeg frames arrive on the SSE
stream and a Runtime.evaluate POST answers on it — the part no fake can tell
you, since only chromium knows whether it likes our Origin and our framing.
Files#
packages/server/src/auth/cdp-bridge.ts— channels, SSE framing, routespackages/server/src/auth/cdp-authz.ts— unchanged rule, renamed toauthorizeCdpAttach(it authorizes a request now, not a frame)packages/portal/src/components/CdpScreencast.tsx— fetch-based SSE clientpackages/server/src/index.ts—@fastify/websocketand both WS routes gone