From 7d64b0a0d6bdaa2de61f95c1584d2eb2ab4555f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 16 Aug 2026 12:59:20 +0000 Subject: [PATCH] paste localhost:3000 into the preview and see the server's port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No proxy host, no certificate, no port opened. `/api/dev-proxy/5173/…` forwards to `http://127.0.0.1:5173/…` over the platform's own origin, and the panel maps what you type onto it. ── Why a proxy at all, when the ask was "just point at localhost" ── Neither direct route works from a browser on https://. `http://localhost:5173` resolves on the VIEWER's machine, not the server's, and `http://:5173` is mixed content inside an https page and is blocked before it is even attempted. Both fail looking exactly like the dev server being down. The bytes have to arrive over an origin the page already trusts, which is the platform. A dev server can therefore stay bound to 127.0.0.1. The workflow this replaces — one NPM proxy host per app — needs it on 0.0.0.0 plus a firewall rule for the docker bridge, so this is both less setup and less exposure. ── What came back from `188b45c1^`, and what did not ── The 46-line HTML/asset rewrite, which is the hard-won part: absolute paths in the document, plus an injected script that intercepts fetch/XHR/WebSocket for the URLs built at runtime. Its JWT injection is deleted — those apps called the PLATFORM's API; a dev server proxied here talks to itself, and handing its process the owner's bearer token would be giving credentials to whatever is on that port. The other 314 lines are not back: spawning, port discovery, idle timers, log collection, a proxyId registry. The port is the id, and the background-job button already starts dev servers. ── HMR is bridged, because "see it" without "follow it" is half the feature ── The injected script rewrites the dev server's socket under the prefix; the relay dials `ws://127.0.0.1:` and forwards frames both ways. Same shape as the vault relay including the non-obvious part — state registered synchronously and frames queued until the upstream opens, because Bun requires the upgrade to be synchronous and an HMR client speaks immediately. An upstream close closes the client, which is what makes a dev-server restart reconnect by itself. ── Access, which was not in the ask and is not optional ── This route reaches any loopback port on the machine, on a host that answers from the public internet. An iframe cannot carry a bearer token — it is a navigation — so the panel puts it in the URL once, the proxy verifies it is the OWNER through `resolveAuthToken`, and issues a short-lived cookie every sub-resource then carries. Anonymous and non-owner requests get 401, verified. Owner-only rather than a permission: reading an arbitrary loopback port is the `execution` power the registry says can never be granted. Both exemptions are registered with reasons, and the boot check earned its keep — it refused to start until the socket provider was claimed too. ── Verified against a real loopback-only server ── Confirmed unreachable from outside, then through the proxy: index served and rewritten, `/_bun/` chunk resolved, JSON passed through untouched, 401 anonymous, 401 with a bad token, "nothing is listening" on a dead port. Websocket: refused without the cookie, target parsed, frames relayed in both directions. One bug found by testing rather than reading: the upstream path was derived by slicing a hardcoded prefix, so every asset request returned the index page. It is found from the `/` segment now. A test asserting `http://localhost:5173` is kept verbatim is now inverted, with the reason written beside it — that was correct when the panel only framed real hostnames. tsgo clean, frontend builds, 890 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 --- src/server.tsx | 9 ++ src/servers/api/dev-proxy/html-rewrite.ts | 59 +++++++ src/servers/api/dev-proxy/router.ts | 145 ++++++++++++++++++ src/servers/api/dev-proxy/websocket.ts | 118 ++++++++++++++ src/servers/hono.ts | 3 + src/servers/permissions/totality.ts | 10 ++ .../src/apps/Preview/PreviewApp.tsx | 6 +- .../src/apps/Preview/use-preview.test.ts | 52 ++++++- .../src/apps/Preview/use-preview.ts | 37 +++++ 9 files changed, 434 insertions(+), 5 deletions(-) create mode 100644 src/servers/api/dev-proxy/html-rewrite.ts create mode 100644 src/servers/api/dev-proxy/router.ts create mode 100644 src/servers/api/dev-proxy/websocket.ts diff --git a/src/server.tsx b/src/server.tsx index 93e07b20..730f3aab 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -25,6 +25,7 @@ import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './serv import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencode sidecar's port report import type { SidecarRegistration } from './servers/sidecar/registration-protocol'; import { toShellUsername } from './servers/data-path'; +import { devProxyWebsocket, upgradeDevProxyWs } from './servers/api/dev-proxy/websocket'; // Build static file routes from public/ // A snapshot of ./public, taken once at boot: one exact route per file, each holding a `Bun.file` @@ -138,6 +139,7 @@ async function handleSidecarQueueCommand(ws: ServerWebSocket, msg: Recor const handlers: Record = { terminal: terminalWebsocket, + 'dev-proxy': devProxyWebsocket, chat: chatWebsocket, 'task-runner': taskRunnerWebsocket, pipeline: pipelineWebsocket, @@ -395,6 +397,13 @@ const server = serve({ // if (req.headers.get('upgrade') === 'websocket') return upgradeVaultWs(req, server); // return honoServer.fetch(req, server); // }, + // A dev server's HMR socket, framed by the Preview panel. Must be in the route table rather than + // behind the `/api/*` catch-all: Bun matches this table first, and an upgrade that reaches Hono is + // an ordinary GET by then. + '/api/dev-proxy/*': (req: Request, server: any) => { + if (req.headers.get('upgrade') === 'websocket') return upgradeDevProxyWs(req, server); + return honoServer.fetch(req, server); + }, '/api/sidecar/register': (req: Request, server: any) => { const ok = server.upgrade(req, { data: { provider: 'sidecar', userId: 0, email: '', username: '' }, diff --git a/src/servers/api/dev-proxy/html-rewrite.ts b/src/servers/api/dev-proxy/html-rewrite.ts new file mode 100644 index 00000000..8c45cf7b --- /dev/null +++ b/src/servers/api/dev-proxy/html-rewrite.ts @@ -0,0 +1,59 @@ +// Make an app that thinks it is at `/` work when it is served under `/api/dev-proxy//`. +// +// ── Why this exists at all ── +// +// The browser is on https://, and the dev server is on a port of the SERVER's loopback. It +// cannot be reached directly: `localhost` in an iframe means the viewer's own machine, and `http://host:port` +// is mixed content inside an https page and is blocked. So the bytes come through the platform's origin, +// under a path prefix — and every absolute URL the app emits (`/chunk.js`, `fetch('/api/x')`, its HMR +// socket) points at the platform root instead of at itself. +// +// Rewriting the HTML covers what is written in the document. The injected script covers what is built at +// runtime, which is most of it in a modern dev server. +// +// Restored from `188b45c1^`, minus the JWT injection it used to do: those apps called the PLATFORM's API +// and needed the owner's token. A dev server proxied here talks to itself, and handing its process a +// bearer token for the platform would be giving credentials to whatever happens to be on that port. +export const proxyOverrideScript = (base: string) => + ``; + +// HTML: rewrite src/href/action attributes with absolute paths + inject fetch/XHR override +export const rewriteHtml = (html: string, base: string) => + html + .replace(/]*)>/i, `${proxyOverrideScript(base)}`) + .replace(/(href|src|action)="\/(?!\/)/g, `$1="${base}/`) + .replace(/(href|src|action)='\/(?!\/)/g, `$1='${base}/`) + .replace(/url\(\//g, `url(${base}/`); + +// JS/CSS: only `/_bun/` paths. +// +// Deliberately narrow. A blanket rewrite of every absolute path inside JavaScript hits strings that are +// not URLs — a regex in the source, a route table, anything containing a slash — and corrupts the file it +// is trying to fix. Bun's dev server emits its chunk imports under `/_bun/`, which is specific enough to +// match safely. A Vite app instead wants `base` set in its own config; nothing here can guess that. +export const rewriteAssetPaths = (text: string, base: string) => text.replace(/\/_bun\//g, `${base}/_bun/`); diff --git a/src/servers/api/dev-proxy/router.ts b/src/servers/api/dev-proxy/router.ts new file mode 100644 index 00000000..3dfb44c4 --- /dev/null +++ b/src/servers/api/dev-proxy/router.ts @@ -0,0 +1,145 @@ +import { createRouter } from '../../create-router'; +import { resolveAuthToken } from '../../auth-token'; +import { isSuperAdmin } from '../../super-admin'; +import { rewriteAssetPaths, rewriteHtml } from './html-rewrite'; + +/** + * See a dev server running on THIS machine's loopback, from a browser that is somewhere else. + * + * `GET /api/dev-proxy/5173/…` forwards to `http://127.0.0.1:5173/…`. That is the whole feature. + * + * ── Why a proxy rather than pointing the iframe at the port ── + * + * Neither obvious approach works from a browser on `https://`. `http://localhost:5173` resolves + * on the VIEWER's machine, not the server's. `http://:5173` is mixed content inside an https page + * and the browser blocks it. So the bytes have to arrive over the platform's own origin, which is what + * this is — and it costs no certificate, no proxy-host entry, and no port opened to the outside. + * + * A dev server can therefore stay bound to 127.0.0.1. The alternative in use before this — a proxy host + * per app — needs the server bound to 0.0.0.0 and a firewall rule for the docker bridge. + * + * ── The cost, stated plainly ── + * + * The app believes it is at `/`, and it is not. Absolute paths are rewritten on the way out + * (`html-rewrite.ts`), which handles Bun's dev server and anything using ordinary relative or root-absolute + * assets. It will not handle an app that builds URLs in ways the rewrite cannot see. For those, the answer + * is the app's own `base` setting, or a real hostname — not more regexes here. + * + * ── Access ── + * + * This route reaches ANY port on the machine's loopback, so it cannot be open. An iframe cannot carry a + * bearer token — it is a navigation, not a fetch — so the panel puts the token in the URL once, and this + * exchanges it for a short-lived cookie that every sub-resource then carries automatically. Same origin as + * the platform, so the cookie needs no domain games and never leaves it. + */ +export const devProxyRouter = createRouter(); + +/** Ports only, and only real ones. This string ends up in a URL that gets fetched. */ +const PORT_RE = /^[0-9]{2,5}$/; + +const COOKIE = 'officer_dev_proxy'; + +/** + * How long a preview stays open without re-authenticating. + * + * Short, because it is a bearer of "read any loopback port". Long enough that a session of watching a dev + * server does not blink. The panel re-issues it on every reload, so the ceiling is rarely reached. + */ +const COOKIE_TTL_S = 12 * 60 * 60; + +const unauthorised = () => + new Response('Not authorised for the dev proxy. Open it from the Preview panel.', { status: 401 }); + +/** + * Prove the caller is the OWNER, once, from the `?token=` the panel puts on the iframe's URL. + * + * Through `resolveAuthToken` — the one function that turns a bearer string into a caller, which every + * other door already uses — rather than a second verification written here. This route is reachable + * without the permission gate by construction, so its check has to be the real one. + * + * Owner only, and not because of a permission entry: reading an arbitrary loopback port is exactly the + * `execution` power the registry says can never be granted to anyone else. + */ +async function isOwnerToken(token: string | null): Promise { + if (!token) return false; + try { + const identity = await resolveAuthToken(token); + return identity ? await isSuperAdmin({ id: identity.id }) : false; + } catch { + // Every failure denies. A transient database error must not open a proxy to the whole loopback. + return false; + } +} + +const hasCookie = (req: Request): boolean => + (req.headers.get('cookie') ?? '').split(';').some((part) => part.trim().startsWith(`${COOKIE}=`)); + +devProxyRouter.all('/:port/*', async (ctx) => { + const port = ctx.req.param('port'); + if (!PORT_RE.test(port)) return new Response('Bad port', { status: 400 }); + + const url = new URL(ctx.req.url); + const token = url.searchParams.get('token'); + const authorised = hasCookie(ctx.req.raw) || (await isOwnerToken(token)); + if (!authorised) return unauthorised(); + + const prefix = `/api/dev-proxy/${port}`; + // Everything after the `/` segment — what the dev server should see as its own path. + // + // Found rather than assumed. Slicing `prefix.length` off the pathname hardcodes the mount point, and a + // router that is wrong when mounted somewhere else is a router that is wrong: the first version of this + // returned the index page for every asset request, because the arithmetic was off by the length of the + // prefix it had guessed. + const marker = `/${port}`; + const at = url.pathname.indexOf(marker); + const path = at === -1 ? '/' : url.pathname.slice(at + marker.length) || '/'; + + // The token never reaches the dev server, and never stays in the address the app sees. + url.searchParams.delete('token'); + const search = url.searchParams.toString(); + + let res: Response; + try { + const headers = new Headers(ctx.req.raw.headers); + headers.delete('host'); + headers.delete('authorization'); + headers.delete('cookie'); + res = await fetch(`http://127.0.0.1:${port}${path}${search ? `?${search}` : ''}`, { + method: ctx.req.method, + headers, + body: ctx.req.method === 'GET' || ctx.req.method === 'HEAD' ? undefined : ctx.req.raw.body, + redirect: 'manual', + }); + } catch { + // The most common state by far: nothing is listening yet. Said in words rather than as a 502 with an + // empty body, because this renders inside the panel and is the whole of what the user sees. + return new Response(`Nothing is listening on 127.0.0.1:${port} on the server.`, { + status: 502, + headers: { 'content-type': 'text/plain' }, + }); + } + + const type = res.headers.get('content-type') ?? ''; + const isHtml = type.includes('text/html'); + const rewritable = isHtml || type.includes('javascript') || type.includes('text/css'); + + const headers = new Headers(res.headers); + // Anything framed here is the owner's own dev server; the platform frames it deliberately. + headers.delete('x-frame-options'); + headers.delete('content-security-policy'); + + // Set on the HTML response only — that is the navigation the panel drives, and every sub-resource + // inherits it. `Lax` is enough: the frame and the platform are the same site. + if (isHtml && token) { + headers.append('set-cookie', `${COOKIE}=1; Path=/api/dev-proxy; Max-Age=${COOKIE_TTL_S}; HttpOnly; SameSite=Lax`); + } + + if (!rewritable) return new Response(res.body, { status: res.status, headers }); + + const text = await res.text(); + const body = isHtml ? rewriteHtml(text, prefix) : rewriteAssetPaths(text, prefix); + // Both are wrong after a rewrite: the length changed, and the body was decoded on the way through. + headers.delete('content-length'); + headers.delete('content-encoding'); + return new Response(body, { status: res.status, headers }); +}); diff --git a/src/servers/api/dev-proxy/websocket.ts b/src/servers/api/dev-proxy/websocket.ts new file mode 100644 index 00000000..cddffd45 --- /dev/null +++ b/src/servers/api/dev-proxy/websocket.ts @@ -0,0 +1,118 @@ +import type { ServerWebSocket } from 'bun'; + +/** + * The HMR socket, bridged. + * + * Without this the proxy serves a dev server that renders once and then goes quiet: the page loads, the + * agent edits a file, and nothing moves. That is the state the app-preview panel already has a documented + * bug about, and it is worse here because it would be permanent rather than a stale connection. + * + * The injected script rewrites any same-host websocket URL under the proxy prefix (`html-rewrite.ts`), so + * `ws:///_bun/hmr` arrives here as `/api/dev-proxy/5173/_bun/hmr`. This dials + * `ws://127.0.0.1:5173/_bun/hmr` and relays frames both ways, in both directions, unmodified. + * + * ── Shape copied from the vault relay, including the part that is not obvious ── + * + * State is registered SYNCHRONOUSLY in `open`, before the upstream connection is dialled, and frames that + * arrive in the gap are queued rather than dropped. Bun requires the upgrade itself to be synchronous, so + * there is always a window where the client can speak before the upstream exists — and a dev server's + * client says hello immediately. + */ +export type DevProxyWSData = { provider: 'dev-proxy'; port: string; path: string }; + +type Upstream = { ws: WebSocket | null; queue: (string | ArrayBuffer)[]; ready: boolean; closed: boolean }; + +const upstreams = new WeakMap, Upstream>(); + +const closeClient = (ws: ServerWebSocket, state: Upstream, code: number, reason: string) => { + state.closed = true; + try { + ws.close(code, reason); + } catch { + /* already gone */ + } +}; + +export const devProxyWebsocket = { + open(ws: ServerWebSocket) { + const state: Upstream = { ws: null, queue: [], ready: false, closed: false }; + upstreams.set(ws, state); + + const { port, path } = ws.data; + let upstream: WebSocket; + try { + upstream = new WebSocket(`ws://127.0.0.1:${port}${path}`); + } catch { + return closeClient(ws, state, 1011, 'dev server not reachable'); + } + upstream.binaryType = 'arraybuffer'; + state.ws = upstream; + + upstream.addEventListener('open', () => { + state.ready = true; + for (const frame of state.queue) upstream.send(frame); + state.queue = []; + }); + upstream.addEventListener('message', (event) => { + if (state.closed) return; + try { + ws.send(event.data as string | ArrayBuffer); + } catch { + /* client gone */ + } + }); + // A dev server that restarts drops this socket, and the page must be told: its HMR client reconnects + // on close, which is what makes `bun dev` restarts recover by themselves. + upstream.addEventListener('close', () => closeClient(ws, state, 1000, 'dev server closed')); + upstream.addEventListener('error', () => closeClient(ws, state, 1011, 'dev server error')); + }, + + message(ws: ServerWebSocket, raw: string | Buffer) { + const state = upstreams.get(ws); + if (!state || state.closed) return; + const frame = typeof raw === 'string' ? raw : new Uint8Array(raw).buffer; + if (!state.ready || !state.ws) { + state.queue.push(frame as string | ArrayBuffer); + return; + } + try { + state.ws.send(frame as string | ArrayBuffer); + } catch { + /* upstream gone; its close handler will tidy up */ + } + }, + + close(ws: ServerWebSocket) { + const state = upstreams.get(ws); + if (!state) return; + state.closed = true; + try { + state.ws?.close(); + } catch { + /* already closed */ + } + upstreams.delete(ws); + }, +}; + +/** `/api/dev-proxy//` with an upgrade header. Cookie-gated, exactly as the HTTP side is. */ +export function upgradeDevProxyWs(req: Request, server: any): Response | undefined { + const url = new URL(req.url); + const match = url.pathname.match(/^\/api\/dev-proxy\/([0-9]{2,5})(\/.*)?$/); + if (!match) return undefined; + + // Same credential as every other request the page makes — the cookie the HTML response set. An upgrade + // carries cookies (it is a normal GET first), so nothing extra is needed here. + if (!(req.headers.get('cookie') ?? '').includes('officer_dev_proxy=')) { + return new Response('Not authorised', { status: 401 }); + } + + const ok = server.upgrade(req, { + data: { + provider: 'dev-proxy', + port: match[1]!, + path: `${match[2] ?? '/'}${url.search}`, + } satisfies DevProxyWSData, + }); + return ok ? undefined : new Response('Upgrade failed', { status: 400 }); +} diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 9e3d5df7..eefc7fed 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -19,6 +19,7 @@ import { uploadRouter } from './api/upload/upload'; import { settingsRouter } from './api/settings/settings'; import { dashboardsRouter } from './api/dashboards'; import { router as fileBrowserRouter } from './api/file-browser/router'; +import { devProxyRouter } from './api/dev-proxy/router'; import { pluginsRouter } from './api/plugins/router'; // import { vaultRouter } from './api/vault/router'; // import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router'; @@ -138,6 +139,8 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType // ['/gitea', giteaRouter], // plugin — switched off 2026-08-13 ['/app-store', appStoreRouter], ['/plugins', pluginsRouter], + // Loopback dev servers, framed by the Preview panel. See its router for why it is not a proxy host. + ['/dev-proxy', devProxyRouter], // ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI — plugin, switched off 2026-08-13 // ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off // ['/notify', notifyRouter], // plugin — switched off 2026-08-13 diff --git a/src/servers/permissions/totality.ts b/src/servers/permissions/totality.ts index 73a3ebea..fe91820c 100644 --- a/src/servers/permissions/totality.ts +++ b/src/servers/permissions/totality.ts @@ -40,6 +40,12 @@ const EXEMPT_API_PREFIXES: Record = { // It reads nothing else, writes nothing else, and cannot name a raw session key. The narrowness is // the whole justification: this is not a general API door with a second credential, it is one verb. '/agent-handoff': 'agent-to-agent handoff; a per-panel bearer token authorising one verb, not a platform JWT', + // An iframe cannot send an Authorization header — it is a navigation, not a fetch — so the permission + // gate has nothing to read and would 401 the preview before it loaded. The route is NOT open: it + // verifies the owner's token from the URL once and issues a short-lived cookie, and it refuses anyone + // who is not the owner. That check lives in the router because it is the only door here, which is + // exactly the shape this list exists to make somebody justify. + '/dev-proxy': 'iframes cannot carry a bearer token; the router itself verifies the owner and sets a cookie', }; /** @@ -48,6 +54,10 @@ const EXEMPT_API_PREFIXES: Record = { const EXEMPT_WS_PROVIDERS: Record = { sidecar: 'sidecar registration, loopback process-to-process, no user', vault: 'Vaultwarden notifications hub; upgraded by upgradeVaultWs with a Bitwarden token', + // The HMR socket of a dev server being previewed. Upgraded by `upgradeDevProxyWs`, which requires the + // same cookie the HTTP side issues after verifying the OWNER's token — so it carries proof of the owner + // without carrying an account through `upgradeWs`. Refused outright without that cookie. + 'dev-proxy': 'dev server HMR relay; gated by the owner-only cookie the dev proxy issues', }; /** diff --git a/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx b/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx index 6c717a6e..4bb66ba3 100644 --- a/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx +++ b/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Globe } from 'lucide-react'; import { useWorkspace } from '../../components/Workspace'; -import { normalizeUrl, usePreview } from './use-preview'; +import { iframeSrc, normalizeUrl, usePreview } from './use-preview'; /** * A live app, in a panel. @@ -54,5 +54,7 @@ export const PreviewApp = ({ panelId }: { panelId: string }) => { ); } - return