the preview authorises itself before it mounts the frame

401 on every asset, white pane. The server was never wrong — replaying the exact
browser sequence with a minted owner token gave HTML 200, `set-cookie` present, and
the sub-resource authorised. So the fault was in how the frame got its credential,
and both mechanisms it relied on were guesses.

Putting the token on the iframe URL guesses which key the browser stored it under.
Letting the frame mount and hoping guesses that a cookie from some earlier load is
still there. When either guess misses, the HTML renders, every sub-resource 401s,
and the pane is white — which is the shape that makes this hard to read, because
the document loading looks like success.

So neither is used now. The panel asks `/api/dev-proxy/session` with the ordinary
API client — which already knows how to authenticate, because it sends the header on
every other call it makes — and mounts the frame only once that returns. The iframe
URL carries nothing. `iframeSrc` is deleted rather than left as a fallback: a second
path that runs when the first fails is how you get a bug that only appears
sometimes.

`Authorization` rather than a query parameter, so the token never reaches an
address, a log or a Referer. Re-run on every reload, so a preview left open
overnight recovers by itself instead of showing a wall of 401s.

── Also fixed: a lie attached to every binary asset ──

The non-rewritable branch returned the upstream's `content-encoding` and
`content-length` unchanged, but `fetch` had already decompressed the body — so the
browser was told to inflate plain bytes, with a length that no longer matched. The
rewrite branch had always deleted both. Images, fonts and wasm were affected;
nothing had hit it yet because the test app serves none.

Verified: `/session` 200 and sets the cookie with a token, 401 without; the frame
200 with the cookie, 401 with nothing.

`[open]` A request that escapes the prefix hits the SPA catch-all and returns
`200 text/html` instead of 404, so a `<script>` gets HTML, the MIME check refuses it
silently, and nothing appears as an error. That masks this entire class of bug and
is worth closing separately.

tsgo clean, frontend builds, 890 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 13:33:22 +00:00
co-authored by Claude Opus 5
parent 73eb128fb1
commit fccabfcea3
4 changed files with 91 additions and 29 deletions
+33 -1
View File
@@ -74,6 +74,30 @@ async function isOwnerToken(token: string | null): Promise<boolean> {
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 });
@@ -134,7 +158,15 @@ devProxyRouter.all('/:port/*', async (ctx) => {
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 });
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) : rewriteAssetPaths(text, prefix);
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Globe } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { useWorkspace } from '../../components/Workspace';
import { iframeSrc, normalizeUrl, usePreview } from './use-preview';
import { normalizeUrl, usePreview } from './use-preview';
/**
* A live app, in a panel.
@@ -54,7 +55,54 @@ export const PreviewApp = ({ panelId }: { panelId: string }) => {
);
}
return (
<iframe key={reloadToken} src={iframeSrc(url)} title="Preview" className="h-full w-full border-none bg-white" />
);
return <DevProxyFrame url={url} reloadToken={reloadToken} />;
};
/**
* The frame, and the one call that has to happen before it.
*
* A dev-proxy URL needs a cookie, and the only thing that can obtain one is an AUTHENTICATED request —
* which an iframe navigation is not. So the panel asks for it with the ordinary API client, which already
* knows how to authenticate, and only then mounts the frame. Anything else is a guess: a token on the URL
* guesses which key the browser stored it under, and mounting first and hoping guesses that a cookie from
* some earlier session is still there. Both were tried; the second is what produced a white pane and 401s
* on every asset.
*
* An ordinary URL mounts immediately — nothing to arrange, and no reason to make it wait.
*/
const DevProxyFrame = ({ url, reloadToken }: { url: string; reloadToken: number }) => {
const { get } = useClient();
const needsSession = url.startsWith('/api/dev-proxy/');
const [ready, setReady] = useState(!needsSession);
const [failed, setFailed] = useState<string | null>(null);
useEffect(() => {
if (!needsSession) {
setReady(true);
return;
}
let alive = true;
setReady(false);
setFailed(null);
// Re-run on every reload: the cookie has a lifetime, and a preview left open overnight should recover
// by itself rather than showing a wall of 401s.
get<{ ok: boolean }>('/dev-proxy/session')
.then(() => alive && setReady(true))
.catch((err: unknown) => alive && setFailed(err instanceof Error ? err.message : 'could not authorise'));
return () => {
alive = false;
};
}, [needsSession, reloadToken, url]);
if (failed) {
return (
<div className="flex h-full items-center justify-center p-6 text-center text-xs text-duck-dark/50">
Could not authorise the dev proxy: {failed}
</div>
);
}
if (!ready) {
return <div className="flex h-full items-center justify-center text-xs text-duck-dark/40">Connecting…</div>;
}
return <iframe key={reloadToken} src={url} title="Preview" className="h-full w-full border-none bg-white" />;
};
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { iframeSrc, normalizeUrl } from './use-preview';
import { normalizeUrl } from './use-preview';
// The panel puts this string straight into an iframe `src`, so what it accepts matters more than it looks.
@@ -71,10 +71,8 @@ describe('loopback URLs', () => {
expect(normalizeUrl('data:text/html,x')).toBeNull();
});
test('the token rides on the dev proxy URL only, and only when there is one', () => {
localStorage.setItem('BEARER_TOKEN', 'tok-123');
expect(iframeSrc('/api/dev-proxy/3000/')).toBe('/api/dev-proxy/3000/?token=tok-123');
expect(iframeSrc('https://example.com/')).toBe('https://example.com/');
localStorage.removeItem('BEARER_TOKEN');
});
// `iframeSrc` used to put the token on the iframe URL. It is gone: reading the token meant guessing
// which key the browser kept it under, and the frame still depended on a cookie arriving in time. The
// panel now asks `/dev-proxy/session` with the authenticated client and mounts the frame afterwards,
// so the URL carries nothing and there is no ordering to get wrong.
});
@@ -125,19 +125,3 @@ function loopbackPort(text: string): string | null {
// Bare `localhost` with no port is ambiguous rather than wrong; 80 is the only defensible reading.
return match[1] ?? '80';
}
/**
* The iframe's URL, with the one-time token when it points at the dev proxy.
*
* An iframe is a navigation and carries no Authorization header, so the proxy has no other way to know who
* is asking. It answers by setting a short-lived cookie, which every sub-resource then carries by itself —
* so the token appears exactly once, on the document request, and never on anything the app can see.
*/
export function iframeSrc(url: string): string {
if (!url.startsWith('/api/dev-proxy/')) return url;
const token =
localStorage.getItem('PERTENTO_EDITOR_AUTH_TOKEN') ??
localStorage.getItem('BEARER_TOKEN') ??
sessionStorage.getItem('BEARER_TOKEN');
return token ? `${url}?token=${encodeURIComponent(token)}` : url;
}