fix: support dev server previews over relay

This commit is contained in:
Bohdan Triapitsyn
2026-09-03 11:49:34 +03:00
parent da6ab52a65
commit a87f068256
16 changed files with 504 additions and 36 deletions
@@ -20,7 +20,13 @@ and what made it fragile per framework.
- `client.js` is the local end: it binds a loopback listener on the user's
machine and pipes each accepted connection through one WebSocket. It lives in
this package because it needs a WebSocket client the package already depends
on; the desktop shell drives it over IPC.
on; the desktop shell drives it over IPC for directly reachable HTTP(S)
runtimes.
- Relay-only runtimes use `packages/electron/relay-dev-tunnel.mjs` for the local
listener. Each accepted connection gets an Electron `MessagePort`; the trusted
renderer carries its bytes through the active E2EE relay. This keeps relay
credentials and encryption in their existing renderer owner instead of
duplicating them in Electron main.
- Port discovery is not owned here. `runtime.js` is given the reachable set by
the same dev-server discovery the user's own list is built from.
- The browser panel decides when to tunnel; this module never chooses a target.
@@ -40,9 +46,12 @@ and what made it fragile per framework.
usual origin allowlist applies unchanged. That check is a CSRF defence: a
hostile page can make a browser open a WebSocket carrying ambient cookies,
and the origin is what exposes it.
- With no `Origin` the request must carry client-token auth. A browser cannot
reach this path — the WebSocket API always sends an origin and never lets a
page set an `Authorization` header — so this case is the desktop shell.
- With no `Origin` the request must carry client-token auth or a short-lived
URL token. The bearer case is the desktop main process. The URL-token case
is the trusted renderer carrying the socket through the E2EE relay.
- Through the E2EE relay, the trusted renderer mints a short-lived URL token
and includes it in the virtual WebSocket URL. The relay host and URL-token
allowlists accept exactly `/api/dev-tunnel`, not subpaths.
- Concurrency is capped per host, not per page, because one page load opens
many sockets.
- A connection that cannot be established fails the socket rather than holding
@@ -20,9 +20,9 @@
*
* - With an `Origin` header, the request came from a browser context and the
* usual origin check applies unchanged.
* - With no `Origin`, the request must carry client-token auth. A browser
* cannot reach this path: the WebSocket API always sends an origin and never
* lets a page set an `Authorization` header.
* - With no `Origin`, the request must carry client-token auth or a short-lived
* URL token. The URL-token case is used only by the trusted renderer through
* the E2EE relay; the UI-auth allowlist limits it to this exact path.
*/
import net from 'node:net';
import { WebSocketServer } from 'ws';
@@ -133,7 +133,7 @@ export function createDevTunnelRuntime({
void (async () => {
try {
if (uiAuthController?.enabled) {
const auth = await uiAuthController.resolveAuthContext(req, null, { allowUrlToken: false });
const auth = await uiAuthController.resolveAuthContext(req, null, { allowUrlToken: true });
if (!auth) {
rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
return;
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test } from 'bun:test';
import http from 'node:http';
import net from 'node:net';
import WebSocket from 'ws';
import { createDevTunnelClient } from './client.js';
import { createDevTunnelRuntime, isDevTunnelPath } from './runtime.js';
@@ -228,6 +229,12 @@ describe('dev tunnel authentication', () => {
enabled: true,
resolveAuthContext: async () => ({ type: 'session' }),
};
const urlTokenAuth = {
enabled: true,
resolveAuthContext: async (req, _res, options) => (
options?.allowUrlToken === true && req.url.includes('oc_url_token=good') ? { type: 'client', token: 'url:authenticated' } : null
),
};
test('accepts a bearer-authenticated client that sends no origin', async () => {
const devPort = await startDevServer((_req, res) => res.end('ok'));
@@ -243,6 +250,19 @@ describe('dev tunnel authentication', () => {
expect((await httpGet(localPort, '/')).body).toBe('ok');
});
test('accepts a URL-token client carried by the E2EE relay', async () => {
const devPort = await startDevServer((_req, res) => res.end('relay-ok'));
const host = await startHost({ allowedPorts: [devPort], auth: urlTokenAuth });
const body = await new Promise((resolve, reject) => {
const socket = new WebSocket(`ws://127.0.0.1:${host.port}/api/dev-tunnel?port=${devPort}&oc_url_token=good`);
socket.on('open', () => socket.send('GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n'));
socket.on('message', (data) => resolve(Buffer.from(data).toString()));
socket.on('error', reject);
});
expect(body).toContain('relay-ok');
});
test('rejects a client with no credentials', async () => {
const devPort = await startDevServer((_req, res) => res.end('ok'));
const host = await startHost({ allowedPorts: [devPort], auth: clientAuth });
@@ -41,7 +41,7 @@ Relay is not a separate link format: it is one transport candidate inside the un
Everything a client normally sends to the single OpenChamber origin:
- **HTTP** — REST endpoints and proxied OpenCode SDK calls under `/api/*`, plus `/auth/*` and `/health`.
- **SSE** — long-lived streamed responses (the event stream and notifications). These are just HTTP responses whose body streams; the tunnel needs no special SSE handling.
- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation).
- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation, and desktop dev-server previews).
The host dispatcher restricts tunneled traffic to explicit path allowlists (one for HTTP, one for WS).
+3 -1
View File
@@ -33,7 +33,9 @@ const ALLOWED_WS_PATHS = new Set([
'/api/event/ws',
'/api/terminal/ws',
'/api/dictation/ws',
'/api/dev-tunnel',
]);
export const isAllowedRelayWebSocketPath = (pathname) => ALLOWED_WS_PATHS.has(pathname);
// Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch
// to the loopback origin. content-length is dropped too because the body is
@@ -425,7 +427,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
void sendAbort(streamId, error?.message ?? 'malformed ws open');
return;
}
if (!ALLOWED_WS_PATHS.has(open.path)) {
if (!isAllowedRelayWebSocketPath(open.path)) {
void sendAbort(streamId, 'Path is not allowed through the relay');
return;
}
@@ -1,7 +1,7 @@
import { describe, test, expect } from 'bun:test';
import http from 'node:http';
import { createTunnelHost } from './tunnel-host.js';
import { createTunnelHost, isAllowedRelayWebSocketPath } from './tunnel-host.js';
import { decodeTunnelFrame, encodeTunnelFrame, encodeJsonPayload, TunnelFrameType } from './tunnel-codec.js';
const startLoopback = () =>
@@ -145,3 +145,11 @@ describe('tunnel-host HTTP body forwarding', () => {
await loopback.stop();
});
});
describe('relay host WebSocket allowlist', () => {
test('allows only the exact dev-server tunnel path', () => {
expect(isAllowedRelayWebSocketPath('/api/dev-tunnel')).toBe(true);
expect(isAllowedRelayWebSocketPath('/api/dev-tunnel/')).toBe(false);
expect(isAllowedRelayWebSocketPath('/api/database/ws')).toBe(false);
});
});
@@ -310,6 +310,7 @@ const isUrlAuthWebSocketPath = (pathname) => {
|| pathname === '/api/openchamber/realtime-proxy/ws'
|| pathname === '/api/terminal/ws'
|| pathname === '/api/dictation/ws'
|| pathname === '/api/dev-tunnel'
|| pathname.startsWith('/api/preview/proxy/');
};
@@ -221,6 +221,22 @@ describe('ui auth client credential seam', () => {
};
expect(await auth.ensureSessionToken(dictationWsReq, null)).toBe('client:device-1');
const devTunnelWsReq = {
method: 'GET',
path: '/api/dev-tunnel',
url: `/api/dev-tunnel?port=4322&oc_url_token=${encodeURIComponent(urlToken)}`,
headers: { upgrade: 'websocket' },
};
expect(await auth.ensureSessionToken(devTunnelWsReq, null)).toBe('client:device-1');
const devTunnelSubpathWsReq = {
method: 'GET',
path: '/api/dev-tunnel/private',
url: `/api/dev-tunnel/private?port=4322&oc_url_token=${encodeURIComponent(urlToken)}`,
headers: { upgrade: 'websocket' },
};
expect(await auth.ensureSessionToken(devTunnelSubpathWsReq, null)).toBe(null);
const dictationHttpReq = {
method: 'GET',
path: '/api/dictation/ws',