Two bugs, one line apart, both silent.
The `url(/…)` rule only matched an UNQUOTED target, so `url("/fonts/x.woff2")` — the form every
generator emits — went through untouched. And it only ran on HTML, so a linked stylesheet got the
narrow `/_bun/` rule and nothing else.
Together that meant a site's own @font-face rules kept pointing at `/fonts/…`, which inside the frame
is the platform's root. Every face 404'd and the preview rendered in a system fallback: nothing looked
broken, it just looked like a different site than the one being previewed.
JavaScript keeps the narrow rule — an absolute path in JS may not be a URL. In CSS it always is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
183 lines
8.5 KiB
TypeScript
183 lines
8.5 KiB
TypeScript
import { createRouter } from '../../create-router';
|
|
import { resolveAuthToken } from '../../auth-token';
|
|
import { isSuperAdmin } from '../../super-admin';
|
|
import { rewriteAssetPaths, rewriteCss, 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://<platform>`. `http://localhost:5173` resolves
|
|
* on the VIEWER's machine, not the server's. `http://<server>: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<boolean> {
|
|
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}=`));
|
|
|
|
/**
|
|
* Take a normal authenticated request and turn it into the cookie the iframe needs.
|
|
*
|
|
* This exists because the two ways of getting credentials into a frame are both guesses. A token on the
|
|
* iframe URL guesses which key the browser stored it under; a cookie that must already be there guesses
|
|
* that something set it earlier. The panel's own API client already knows how to authenticate — it sends
|
|
* the Authorization header on every other call it makes — so it calls this first, and the iframe that
|
|
* follows needs to carry nothing at all.
|
|
*
|
|
* `Authorization` rather than the URL, so the token never appears in an address, a log or a Referer.
|
|
*/
|
|
devProxyRouter.get('/session', async (ctx) => {
|
|
const header = ctx.req.header('authorization') ?? '';
|
|
const bearer = header.toLowerCase().startsWith('bearer ') ? header.slice(7).trim() : null;
|
|
if (!(await isOwnerToken(bearer))) return unauthorised();
|
|
|
|
return new Response(JSON.stringify({ ok: true }), {
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'set-cookie': `${COOKIE}=1; Path=/api/dev-proxy; Max-Age=${COOKIE_TTL_S}; HttpOnly; SameSite=Lax`,
|
|
},
|
|
});
|
|
});
|
|
|
|
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 `/<port>` 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 isCss = type.includes('text/css');
|
|
const rewritable = isHtml || isCss || type.includes('javascript');
|
|
|
|
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) {
|
|
// `fetch` decompressed this on the way in, so the upstream's `content-encoding` now describes a body
|
|
// that no longer exists and `content-length` counts bytes that are no longer there. The browser would
|
|
// try to inflate plain bytes. The rewrite branch below has always deleted both; this one did not, so
|
|
// every image, font and wasm file was served with a lie attached.
|
|
headers.delete('content-length');
|
|
headers.delete('content-encoding');
|
|
return new Response(res.body, { status: res.status, headers });
|
|
}
|
|
|
|
const text = await res.text();
|
|
const body = isHtml
|
|
? rewriteHtml(text, prefix)
|
|
: isCss
|
|
? rewriteCss(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 });
|
|
});
|