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 });
|
||||
|
||||
Reference in New Issue
Block a user