fix: support dev server previews over relay
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user