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 });