fix(webview): let a proxied single-page app route on its own path, and recover a frame that reloads

A dashboard served through a web tab saw `/webview/<cap>/` as its
`location.pathname`, and no app has a route for that: a React Router, Vue
Router or Vite dev-server page painted its HTML and CSS and then replaced
them with its own "page not found" the moment its script ran (reproduced
with a minimal history-routed page).

The proxy's runtime shim now rewrites the history entry to the path the
page would see on its own origin, before any page script runs. The base
element still resolves relative URLs inside the prefix and every root-
absolute sink is rewritten back into it, so only what the page READS
changes. With the document URL masked the Referer-keyed 404 rescue can no
longer help a request the shim misses, so the remaining URL-taking entry
points (`Worker`, `SharedWorker`, `navigator.sendBeacon`, `window.open`)
are covered by the shim as well.

A navigation the page starts itself afterwards — `location.reload()`
(a dev server's full-reload HMR), a root-absolute `location.href` — lands
on Codeman's root with no capability anywhere: no prefix in the path, no
cookie in an opaque-origin frame, a Referer naming the masked page. It is
recognised by shape (a top-level iframe navigation asking for HTML, for a
path Codeman does not serve) and answered with a static page whose only
script posts `{type:'codeman:webview-lost', path}` to the parent; the tab
that owns the frame (matched by `event.source`, never by the payload)
remounts it inside the prefix at that path, bounded per frame. The
unauthenticated form is answered in the auth middleware before the
credential checks, so a dev server that reloads on every save cannot
rate-limit its own user out of Codeman; the authenticated form (Basic
auth, trusted mode) is answered by the 404 handler.

Verified end to end against a history-routed page: boots on `/`, its
API call succeeds, a reload inside the frame comes back routed on the
path it had pushed, `location.href = '/about'` comes back on `/about`,
and a deep link opens on its path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
shenlvkang-collab
2026-09-10 14:13:15 +08:00
co-authored by Claude Fable 5.1
parent d9eeb039db
commit 349a89ec3b
9 changed files with 424 additions and 10 deletions
+18
View File
@@ -0,0 +1,18 @@
---
"aicodeman": patch
---
fix(webview): let a proxied single-page app route on its own path, and recover a frame that reloads
A dashboard served through a web tab saw `/webview/<cap>/` as its `location.pathname`, and
no app has a route for that: a React Router, Vue Router or Vite dev-server page painted its
HTML and CSS and then replaced them with its own "page not found" the moment its script ran.
The proxy's runtime shim now rewrites the history entry to the path the page would see on its
own origin before any page script runs, while every URL the page emits still goes through
the existing rewrite layers (plus `Worker`, `sendBeacon` and `window.open`, which the masked
Referer can no longer rescue). A navigation the page starts itself afterwards — a dev
server's full-reload HMR, a root-absolute `location.href` — lands on Codeman's root with no
capability; it is recognised by shape (an iframe navigation asking for HTML for a path Codeman
does not serve), answered with a static page that tells the owning tab which path was lost,
and the tab remounts the frame inside the prefix at that path. That answer is served before
the credential checks, so it never counts as a failed login.
+21 -4
View File
@@ -147,6 +147,21 @@ layers cooperate so a dashboard talking to its own backend just works:
using its `Referer` to identify the dashboard. This only fires for a request using its `Referer` to identify the dashboard. This only fires for a request
that already missed every Codeman route, and never for one that resolves to a that already missed every Codeman route, and never for one that resolves to a
real route, which is what keeps it from being an authentication bypass. real route, which is what keeps it from being an authentication bypass.
5. The same script **masks the proxy prefix off the page's own URL** before any
of the page's code runs (`history.replaceState` to the path the page would see
on its own origin). A single-page app routes on `location.pathname` at boot,
and `/webview/<cap>/` is a path no app has a route for: without this, a React
Router / Vue Router / Next dev server painted its HTML and CSS and then replaced
them with its own "page not found" the moment its script ran. The page only
*reads* the masked path; every URL it emits still goes through the layers above.
6. A navigation the page starts **itself** after that — `location.reload()` (a dev
server's full-reload HMR), a root-absolute `location.href = '/login'` — now
targets Codeman's root with no capability anywhere on it. Codeman recognises
that request by shape (a top-level `<iframe>` navigation asking for HTML, for a
path it does not serve) and answers a static page that does nothing but tell
the owning tab which path was lost; the tab remounts the frame inside the
prefix at that path. It never counts as a failed login, so a dev server that
reloads on every save cannot rate-limit its user out of Codeman.
On top of that, the proxy answers those requests with CORS headers. That sounds On top of that, the proxy answers those requests with CORS headers. That sounds
wrong for same-host requests, but a sandboxed iframe has an *opaque* origin, so the wrong for same-host requests, but a sandboxed iframe has an *opaque* origin, so the
@@ -160,10 +175,12 @@ then every API call fails, which looks like the dashboard being broken.
EventSource, normal markup, the DOM sinks a page uses to build markup at runtime, EventSource, normal markup, the DOM sinks a page uses to build markup at runtime,
and `url()` inside stylesheets. Something that constructs requests by an unusual and `url()` inside stylesheets. Something that constructs requests by an unusual
route can still slip through. Symptom: the page renders but a panel stays empty. route can still slip through. Symptom: the page renders but a panel stays empty.
- **Root-absolute `location` navigation.** A dashboard that navigates itself with - **Root-absolute `location` navigation is recovered, not prevented.** `Location`
`location.href = '/login'` escapes the prefix, because `Location.href` is is unforgeable, so `location.href = '/login'` or `location.reload()` really does
unforgeable and cannot be patched the way the other sinks are. A relative leave the prefix; the frame comes back through the recovery hop in layer 6 above,
`location.href = 'login'` is fine (`<base>` covers it). which needs a browser that sends `Sec-Fetch-Dest` (every current one; iOS Safari
since 16.4). Older browsers show Codeman's 404 in the frame; the tab's **Reload**
button puts it back.
- **Cross-origin redirects are not followed.** If a dashboard bounces to a different - **Cross-origin redirects are not followed.** If a dashboard bounces to a different
host (an external SSO provider, say), the proxy hands the redirect back unchanged host (an external SSO provider, say), the proxy hands the redirect back unchanged
rather than relaying it, because relaying would make this an open proxy. Use rather than relaying it, because relaying would make this an open proxy. Use
+35 -1
View File
@@ -23,7 +23,13 @@ import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
import { isMultiUserMode } from '../../config/multiuser.js'; import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js'; import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
import { webviewCapabilities } from '../../webview-capabilities.js'; import { webviewCapabilities } from '../../webview-capabilities.js';
import { capabilityFromProxyPath, capabilityFromReferer } from '../webview-proxy.js'; import {
capabilityFromProxyPath,
capabilityFromReferer,
isLostWebviewFrameNavigation,
lostWebviewFramePage,
LOST_FRAME_PAGE_CSP,
} from '../webview-proxy.js';
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js'; import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
// Request-scoped identity (multi-user). Single-user leaves it undefined and the // Request-scoped identity (multi-user). Single-user leaves it undefined and the
@@ -176,6 +182,30 @@ function hasValidWebviewCapability(req: FastifyRequest, basePath = ''): boolean
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined; return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
} }
/**
* A web-tab frame that navigated itself off the proxy prefix (see
* isLostWebviewFrameNavigation). It cannot authenticate: opaque origin, no cookie,
* no capability left in the URL. Answer with the static recovery page here, BEFORE
* the credential checks, so the reload of a proxied dashboard neither shows a
* login challenge inside the tab nor counts as a failed attempt against the
* caller's IP — a dev server that full-reloads on every save would otherwise
* rate-limit its own user out of Codeman. Fenced like the Referer exemption: a
* path that resolves to a real route (the app shell, /api, /q) is never answered
* this way, so a genuine unauthenticated navigation still gets the 401.
*
* @returns true when the reply was sent.
*/
function serveLostWebviewFrame(req: FastifyRequest, reply: FastifyReply): boolean {
if (!isLostWebviewFrameNavigation(req)) return false;
const url = (req.url ?? '').split('?')[0];
if (url === '/' || url.startsWith('/api/') || url.startsWith('/ws/') || url.startsWith('/q/')) return false;
if (matchesRegisteredRoute(req, url)) return false;
reply.header('content-security-policy', LOST_FRAME_PAGE_CSP);
reply.header('cache-control', 'no-store');
reply.type('text/html; charset=utf-8').send(lostWebviewFramePage());
return true;
}
/** /**
* Whether `url` resolves to a route Codeman actually registered. * Whether `url` resolves to a route Codeman actually registered.
* *
@@ -302,6 +332,8 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
done(); done();
return; return;
} }
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
if (serveLostWebviewFrame(req, reply)) return;
const clientIp = req.ip; const clientIp = req.ip;
@@ -439,6 +471,8 @@ function registerMultiUserAuthHook(
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter // ownership against the identity BOUND TO THE CAPABILITY, which is stricter
// than re-deriving it from a request that carries no credentials. // than re-deriving it from a request that carries no credentials.
if (hasValidWebviewCapability(req, basePath)) return; if (hasValidWebviewCapability(req, basePath)) return;
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
if (serveLostWebviewFrame(req, reply)) return;
const clientIp = req.ip; const clientIp = req.ip;
+49 -4
View File
@@ -138,6 +138,7 @@ Object.assign(CodemanApp.prototype, {
this._webviewFrameLru = this._webviewFrameLru || []; this._webviewFrameLru = this._webviewFrameLru || [];
await this.refreshWebviews(); await this.refreshWebviews();
this._installWebviewLostListener();
// Restore the previously open web tabs (per device: which dashboards you keep // Restore the previously open web tabs (per device: which dashboards you keep
// open is a workspace-layout choice, not something to sync across machines). // open is a workspace-layout choice, not something to sync across machines).
@@ -167,6 +168,48 @@ Object.assign(CodemanApp.prototype, {
this.renderSessionTabs(); this.renderSessionTabs();
}, },
/**
* Take back a frame that navigated itself off its proxy prefix.
*
* The proxy's runtime shim masks `/webview/<cap>/` off the document URL so a
* single-page app routes on the path it expects. A navigation the page then
* starts itself — `location.reload()` (a dev server's full-reload HMR), a
* root-absolute `location.href = '/login'` — lands on Codeman's root with no
* capability, where the server answers a static page that does nothing but
* post `{type:'codeman:webview-lost', path}` here. The frame is identified by
* `event.source` against the iframes this tab mounted (never by the payload),
* and remounted inside the prefix at that path. Bounded per frame so a page
* that reloads itself on every boot cannot spin.
*/
_installWebviewLostListener() {
if (this._webviewLostListener) return;
this._webviewLostListener = (event) => {
const data = event.data;
if (!data || typeof data !== 'object' || data.type !== 'codeman:webview-lost') return;
if (typeof data.path !== 'string' || !event.source) return;
const layer = document.getElementById('webviewLayer');
if (!layer) return;
for (const wrap of layer.querySelectorAll('.webview-frame')) {
const frame = wrap.querySelector('iframe');
if (!frame || frame.contentWindow !== event.source) continue;
const id = wrap.dataset.webviewId;
if (!id || !this.webviews?.has(id)) return;
const now = Date.now();
this._webviewRecoveries = this._webviewRecoveries || new Map();
const recent = (this._webviewRecoveries.get(id) || []).filter((at) => now - at < 60000);
if (recent.length >= 5) return;
recent.push(now);
this._webviewRecoveries.set(id, recent);
// Path only, never an origin: a `//host/x` here would jump the frame off
// the proxy (resolveUpstreamUrl refuses it server-side as well).
const path = data.path.replace(/^\/+/, '/');
void this.openWebview(id, { path: path.startsWith('/') && !path.startsWith('//') ? path : '/' });
return;
}
};
window.addEventListener('message', this._webviewLostListener);
},
_persistWebviewOrder() { _persistWebviewOrder() {
try { try {
localStorage.setItem('codeman-webview-order', JSON.stringify(this.webviewOrder || [])); localStorage.setItem('codeman-webview-order', JSON.stringify(this.webviewOrder || []));
@@ -264,13 +307,15 @@ Object.assign(CodemanApp.prototype, {
if (data.webview) this.webviews.set(id, data.webview); if (data.webview) this.webviews.set(id, data.webview);
let src = data.embedUrl || data.webview?.url || webview.url; let src = data.embedUrl || data.webview?.url || webview.url;
const path = typeof options.path === 'string' ? options.path : ''; // A string `path` (even '') means "go there": the proxy prefix is
// `/webview/<cap>/` and the wildcard rides after it; in direct mode the deep
// link resolves against the dashboard's own origin. No `path` means "show
// the tab", leaving a mounted frame on whatever page it reached.
const path = typeof options.path === 'string' ? options.path : null;
if (path) { if (path) {
// The proxy prefix is `/webview/<cap>/`; a wildcard rides after it. In
// direct mode the deep link resolves against the dashboard's own origin.
src = data.embedUrl ? `${data.embedUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}` : new URL(path, src).href; src = data.embedUrl ? `${data.embedUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}` : new URL(path, src).href;
} }
this._mountWebviewFrame(id, src, data.webview || webview, { navigate: !!path }); this._mountWebviewFrame(id, src, data.webview || webview, { navigate: path !== null });
this.activeWebviewId = id; this.activeWebviewId = id;
this.hideWelcome?.(); this.hideWelcome?.();
document.querySelector('.main')?.classList.add('webview-active'); document.querySelector('.main')?.classList.add('webview-active');
+10
View File
@@ -181,6 +181,7 @@ import {
registerTabLayoutRoutes, registerTabLayoutRoutes,
tryWebviewRefererFallback, tryWebviewRefererFallback,
} from './routes/index.js'; } from './routes/index.js';
import { isLostWebviewFrameNavigation, lostWebviewFramePage, LOST_FRAME_PAGE_CSP } from './webview-proxy.js';
import { CronService } from '../cron/cron-service.js'; import { CronService } from '../cron/cron-service.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -976,6 +977,15 @@ export class WebServer extends EventEmitter {
// and the relay declines unless the Referer carries a live capability, so // and the relay declines unless the Referer carries a live capability, so
// genuinely unknown `/api` paths still get the envelope below. // genuinely unknown `/api` paths still get the envelope below.
if (await tryWebviewRefererFallback(req, reply, this.basePath)) return reply; if (await tryWebviewRefererFallback(req, reply, this.basePath)) return reply;
// An authenticated web-tab frame (Basic auth, or trusted mode with a cookie)
// that navigated itself off its proxy prefix: the runtime shim masks the
// prefix so the page's router sees its own path, and a reload of that page
// lands here. The unauthenticated form is answered in the auth middleware.
if (!req.url.startsWith('/api') && isLostWebviewFrameNavigation(req)) {
reply.header('content-security-policy', LOST_FRAME_PAGE_CSP);
reply.header('cache-control', 'no-store');
return reply.type('text/html; charset=utf-8').send(lostWebviewFramePage());
}
if (req.url.startsWith('/api')) { if (req.url.startsWith('/api')) {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound)); return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
} }
+85 -1
View File
@@ -38,6 +38,7 @@
* that everything else here works to preserve. * that everything else here works to preserve.
*/ */
import { createHash } from 'node:crypto';
import { WEBVIEW_PROXY_PREFIX } from '../config/webview-limits.js'; import { WEBVIEW_PROXY_PREFIX } from '../config/webview-limits.js';
import { stripBasePath } from '../config/base-path.js'; import { stripBasePath } from '../config/base-path.js';
@@ -425,6 +426,21 @@ export function runtimeUrlShim(prefix: string): string {
// and a throw here would break the dashboard rather than fix it. // and a throw here would break the dashboard rather than fix it.
return `<script>(function(){try{ return `<script>(function(){try{
var P=${JSON.stringify(prefix)}; var P=${JSON.stringify(prefix)};
// Route masking. A single-page app reads location.pathname on boot and routes
// on it; through the proxy that path starts with /webview/<cap>/, which no app
// has a route for, so it rendered its own "page not found" the moment its
// script ran — after the HTML and CSS had already painted. Replace the entry
// with the path the page would see on its own origin. The base element still resolves
// relative URLs inside the prefix, and every root-absolute sink below is
// rewritten back into it, so only what the page READS changes. The parent
// tab remounts the frame if the page ever navigates itself off the prefix
// (see lostWebviewFramePage), which is what makes a masked reload survivable.
try{
var L=location.pathname;
if(L.indexOf(P)===0&&window.history&&typeof history.replaceState==='function'){
history.replaceState(history.state,'',L.slice(P.length-1)+location.search+location.hash);
}
}catch(e){}
function rw(u){ function rw(u){
try{ try{
if(u==null)return u; if(u==null)return u;
@@ -457,13 +473,24 @@ if(window.XMLHttpRequest&&XMLHttpRequest.prototype.open){
var a=[].slice.call(arguments);a[1]=rw(u);return oo.apply(this,a); var a=[].slice.call(arguments);a[1]=rw(u);return oo.apply(this,a);
}; };
} }
['WebSocket','EventSource'].forEach(function(k){ ['WebSocket','EventSource','Worker','SharedWorker'].forEach(function(k){
var C=window[k];if(!C)return; var C=window[k];if(!C)return;
function W(u,p){return p===undefined?new C(rw(u)):new C(rw(u),p);} function W(u,p){return p===undefined?new C(rw(u)):new C(rw(u),p);}
W.prototype=C.prototype; W.prototype=C.prototype;
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(function(s){if(s in C)W[s]=C[s];}); ['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(function(s){if(s in C)W[s]=C[s];});
window[k]=W; window[k]=W;
}); });
// With the document URL masked, a request the shim misses can no longer be
// rescued by its Referer (that carried the prefix), so the remaining
// URL-taking entry points are covered here rather than left to the fallback.
if(window.navigator&&typeof navigator.sendBeacon==='function'){
var ob=navigator.sendBeacon;
navigator.sendBeacon=function(u,d){return ob.call(navigator,rw(u),d);};
}
if(typeof window.open==='function'){
var ow=window.open;
window.open=function(u){var a=[].slice.call(arguments);a[0]=rw(u);return ow.apply(this,a);};
}
var A=['src','href','action','poster','data','formaction','srcset']; var A=['src','href','action','poster','data','formaction','srcset'];
function rwSet(v){ function rwSet(v){
try{ try{
@@ -680,3 +707,60 @@ export function upstreamWebSocketUrl(target: URL): string {
ws.protocol = ws.protocol === 'https:' ? 'wss:' : 'ws:'; ws.protocol = ws.protocol === 'https:' ? 'wss:' : 'ws:';
return ws.href; return ws.href;
} }
// ───────────────────────── Lost-frame recovery ─────────────────────────
/**
* The script the recovery page runs. Kept as a constant so its CSP hash below
* is computed from the exact bytes that are served.
*/
const LOST_FRAME_SCRIPT = `(function(){try{
var path=location.pathname+location.search+location.hash;
if(window.parent&&window.parent!==window){window.parent.postMessage({type:'codeman:webview-lost',path:path},'*');}
}catch(e){}})();`;
const LOST_FRAME_SCRIPT_HASH = createHash('sha256').update(LOST_FRAME_SCRIPT, 'utf8').digest('base64');
/** CSP for the recovery page: nothing but its own hashed inline script. */
export const LOST_FRAME_PAGE_CSP = `default-src 'none'; script-src 'sha256-${LOST_FRAME_SCRIPT_HASH}'; style-src 'unsafe-inline'`;
/**
* Whether this request is a web-tab frame that has navigated off its proxy prefix.
*
* The runtime shim masks `/webview/<cap>/` off the document URL so a single-page
* app routes on the path it expects. The price is that a navigation the page
* starts ITSELF — `location.reload()` (a dev server's full-reload HMR), a
* root-absolute `location.href = '/login'` — now targets Codeman's own root with
* no capability anywhere on it: no prefix in the path, no cookie in an
* opaque-origin frame, and a Referer that names the masked page. Such a request
* is recognisable by shape alone: a top-level navigation of an `<iframe>`
* (`Sec-Fetch-Dest`), asking for HTML, for a path Codeman does not serve.
*
* The answer is `lostWebviewFramePage()`, a static page whose only content is a
* `postMessage` to the parent naming the path; the Codeman tab that owns the
* frame remounts it inside the prefix at that path. Nothing is exempted from
* auth by this except that static page, which carries no data.
*/
export function isLostWebviewFrameNavigation(req: {
method: string;
headers: Record<string, string | string[] | undefined>;
}): boolean {
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
const dest = req.headers['sec-fetch-dest'];
if (dest !== 'iframe' && dest !== 'frame') return false;
const mode = req.headers['sec-fetch-mode'];
if (mode !== undefined && mode !== 'navigate') return false;
const accept = req.headers.accept;
return typeof accept === 'string' && accept.includes('text/html');
}
/** The static page that hands a lost frame back to its owning tab. */
export function lostWebviewFramePage(): string {
return (
'<!doctype html><html><head><meta charset="utf-8"><title>Reconnecting</title>' +
'<meta name="referrer" content="no-referrer"></head>' +
'<body style="margin:0;font:14px system-ui,sans-serif;color:#888;padding:16px">' +
'Reconnecting this web tab…' +
`<script>${LOST_FRAME_SCRIPT}</script></body></html>`
);
}
+38
View File
@@ -236,3 +236,41 @@ describe('authenticated access is unaffected', () => {
expect((await app.inject({ method: 'GET', url: '/', headers: { authorization: wrong } })).statusCode).toBe(401); expect((await app.inject({ method: 'GET', url: '/', headers: { authorization: wrong } })).statusCode).toBe(401);
}); });
}); });
/**
* A web-tab frame that navigated itself off its proxy prefix. The runtime shim
* masks `/webview/<cap>/` off the document URL so a single-page app routes on its
* own path; a reload of that page (a dev server's full-reload HMR) then targets
* Codeman's root with no capability, no cookie (opaque origin) and a Referer that
* names the masked page. It gets the static recovery page, not a login challenge,
* and it must not count as an auth failure.
*/
describe('a lost web-tab frame', () => {
const lostFrame = { 'sec-fetch-dest': 'iframe', 'sec-fetch-mode': 'navigate', accept: 'text/html,*/*;q=0.8' };
it('gets the recovery page instead of a 401', async () => {
const res = await app.inject({ method: 'GET', url: '/about?tab=2', headers: lostFrame });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('text/html');
expect(res.headers['content-security-policy']).toContain("default-src 'none'");
expect(res.body).toContain('codeman:webview-lost');
});
it('never for a path Codeman actually serves, and never for a plain navigation', async () => {
expect((await app.inject({ method: 'GET', url: '/', headers: lostFrame })).statusCode).toBe(401);
expect((await app.inject({ method: 'GET', url: '/api/sessions/abc', headers: lostFrame })).statusCode).toBe(401);
expect((await app.inject({ method: 'GET', url: '/about' })).statusCode).toBe(401);
expect(
(await app.inject({ method: 'GET', url: '/about', headers: { ...lostFrame, 'sec-fetch-dest': 'document' } }))
.statusCode
).toBe(401);
});
it('does not count against the auth failure limit', async () => {
for (let i = 0; i < 20; i += 1) {
expect((await app.inject({ method: 'GET', url: `/reload-${i}`, headers: lostFrame })).statusCode).toBe(200);
}
// A genuinely unauthenticated request afterwards is still a plain 401, not a 429.
expect((await app.inject({ method: 'GET', url: '/static/app.js' })).statusCode).toBe(401);
});
});
+71
View File
@@ -40,6 +40,7 @@ interface AppLike {
openWebview(id: string, options?: { path?: string }): Promise<void>; openWebview(id: string, options?: { path?: string }): Promise<void>;
_apiJson(path: string, opts?: { method?: string; body?: unknown }): Promise<unknown>; _apiJson(path: string, opts?: { method?: string; body?: unknown }): Promise<unknown>;
_updateActiveWebviewTab(): void; _updateActiveWebviewTab(): void;
_installWebviewLostListener(): void;
showToast?: (msg: string, kind: string) => void; showToast?: (msg: string, kind: string) => void;
} }
@@ -226,3 +227,73 @@ describe('callers consult the hook first', () => {
expect(section.indexOf('openLinkThroughWebTabIfLoopback?.(urlLink.href)')).toBeGreaterThan(urlHandler); expect(section.indexOf('openLinkThroughWebTabIfLoopback?.(urlLink.href)')).toBeGreaterThan(urlHandler);
}); });
}); });
/**
* Lost-frame recovery: the server's recovery page posts `{type, path}` to the
* parent; the tab that owns the frame remounts it inside the prefix at that path.
*/
describe('lost-frame recovery', () => {
const lost = (win: Window, source: unknown, path: unknown) =>
win.dispatchEvent(
new (win as unknown as { MessageEvent: typeof MessageEvent }).MessageEvent('message', {
data: { type: 'codeman:webview-lost', path },
source: source as Window,
})
);
const frameOf = (win: Window, id: string) =>
win.document.querySelector(`.webview-frame[data-webview-id="${id}"] iframe`) as HTMLIFrameElement;
it('remounts the frame that sent the message at the path it lost, inside the prefix', async () => {
const { win, app } = boot();
app._installWebviewLostListener();
await app.openWebview('dev');
const frame = frameOf(win, 'dev');
lost(win, frame.contentWindow, '/about?tab=2#top');
await vi.waitFor(() => expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/about?tab=2#top'));
expect(frameOf(win, 'dev')).toBe(frame);
});
it('recovers to the landing page for a bare reload', async () => {
const { win, app } = boot();
app._installWebviewLostListener();
await app.openWebview('dev');
await app.openUrlInWebTab('http://localhost:5173/deep');
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/deep');
lost(win, frameOf(win, 'dev').contentWindow, '/');
await vi.waitFor(() => expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/'));
});
it('ignores a message that did not come from one of its frames, or is malformed', async () => {
const { win, app, calls } = boot();
app._installWebviewLostListener();
await app.openWebview('dev');
const before = calls.length;
lost(win, win, '/elsewhere');
lost(win, frameOf(win, 'dev').contentWindow, 42);
win.dispatchEvent(
new (win as unknown as { MessageEvent: typeof MessageEvent }).MessageEvent('message', {
data: 'codeman:webview-lost',
source: frameOf(win, 'dev').contentWindow as Window,
})
);
await new Promise((r) => setTimeout(r, 20));
expect(calls.length).toBe(before);
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/');
});
it('never lets the path jump the frame off the proxy, and bounds a reload loop', async () => {
const { win, app, calls } = boot();
app._installWebviewLostListener();
await app.openWebview('dev');
lost(win, frameOf(win, 'dev').contentWindow, '//evil.example/x');
await vi.waitFor(() => expect(calls.filter((c) => c.path.endsWith('/open')).length).toBe(2));
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/');
const opensBefore = calls.filter((c) => c.path.endsWith('/open')).length;
for (let i = 0; i < 10; i += 1) lost(win, frameOf(win, 'dev').contentWindow, `/spin-${i}`);
await new Promise((r) => setTimeout(r, 50));
const opens = calls.filter((c) => c.path.endsWith('/open')).length - opensBefore;
expect(opens).toBeLessThanOrEqual(5);
expect(opens).toBeGreaterThan(0);
});
});
+97
View File
@@ -27,6 +27,9 @@ import {
runtimeUrlShim, runtimeUrlShim,
stripFrameAncestors, stripFrameAncestors,
upstreamWebSocketUrl, upstreamWebSocketUrl,
isLostWebviewFrameNavigation,
lostWebviewFramePage,
LOST_FRAME_PAGE_CSP,
} from '../src/web/webview-proxy.js'; } from '../src/web/webview-proxy.js';
const CAP = 'A'.repeat(32); const CAP = 'A'.repeat(32);
@@ -720,3 +723,97 @@ describe('reverse-proxy base path', () => {
expect(capabilityFromReferer(`https://box.ts.net${BASED_PREFIX}page`)).toBeNull(); expect(capabilityFromReferer(`https://box.ts.net${BASED_PREFIX}page`)).toBeNull();
}); });
}); });
/**
* Route masking. A single-page app routes on `location.pathname` at boot; through
* the proxy that path starts with `/webview/<cap>/`, which no app has a route for,
* so it rendered its own "page not found" the moment its script ran (measured
* against a minimal history-routed page: HTML and CSS painted, then the router
* replaced them). The shim rewrites the history entry to the path the page would
* see on its own origin, before any page script runs.
*/
describe('runtimeUrlShim route masking', () => {
const body = runtimeUrlShim(PREFIX)
.replace(/^<script>/, '')
.replace(/<\/script>$/, '');
function boot(url: string) {
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>', {
url,
runScripts: 'outside-only',
});
dom.window.eval(body);
return dom.window;
}
it('masks the proxy prefix off the document URL so the router sees its own path', () => {
const win = boot(`https://codeman.local${PREFIX}pages/report?tab=2#top`);
expect(win.location.pathname).toBe('/pages/report');
expect(win.location.search).toBe('?tab=2');
expect(win.location.hash).toBe('#top');
});
it('maps the landing page to /', () => {
expect(boot(`https://codeman.local${PREFIX}`).location.pathname).toBe('/');
});
it('keeps rewriting root-absolute URLs into the prefix after masking', () => {
const win = boot(`https://codeman.local${PREFIX}`);
const calls: string[] = [];
// Patched fetch is a closure over the ORIGINAL window.fetch; jsdom has none,
// so exercise the same rewrite through an <a> href, which is a patched sink.
const a = win.document.createElement('a');
a.href = '/api/data';
calls.push(a.getAttribute('href') ?? '');
expect(calls).toEqual([`${PREFIX}api/data`]);
});
it('leaves a document that is not under the prefix alone', () => {
const win = boot('https://codeman.local/somewhere/else');
expect(win.location.pathname).toBe('/somewhere/else');
});
it('patches window.open so a popup stays inside the prefix', () => {
const win = boot(`https://codeman.local${PREFIX}`);
const opened: string[] = [];
// The shim wrapped the original; swap the original out from under it by
// re-running against a stub is not possible, so verify the wrapper shape.
const original = win.open;
expect(typeof original).toBe('function');
Object.defineProperty(win, 'open', { value: (u: string) => opened.push(u), configurable: true });
// A second shim run wraps the stub (the __cmrw guard applies to setters, not
// to window.open, which has no marker of its own to preserve).
win.eval(body);
win.open('/print');
expect(opened).toEqual([`${PREFIX}print`]);
});
});
describe('lost-frame recovery', () => {
const nav = (headers: Record<string, string>, method = 'GET') => isLostWebviewFrameNavigation({ method, headers });
it('recognises a top-level iframe navigation asking for HTML', () => {
expect(nav({ 'sec-fetch-dest': 'iframe', 'sec-fetch-mode': 'navigate', accept: 'text/html,*/*' })).toBe(true);
expect(nav({ 'sec-fetch-dest': 'frame', accept: 'text/html' })).toBe(true);
});
it('refuses subresource fetches, non-HTML accepts, and writes', () => {
expect(nav({ 'sec-fetch-dest': 'script', 'sec-fetch-mode': 'no-cors', accept: '*/*' })).toBe(false);
expect(nav({ 'sec-fetch-dest': 'iframe', 'sec-fetch-mode': 'cors', accept: 'text/html' })).toBe(false);
expect(nav({ 'sec-fetch-dest': 'iframe', accept: 'application/json' })).toBe(false);
expect(nav({ accept: 'text/html' })).toBe(false);
expect(nav({ 'sec-fetch-dest': 'iframe', accept: 'text/html' }, 'POST')).toBe(false);
});
it('serves a page whose only script posts the lost path to the parent, and hashes it in the CSP', async () => {
const page = lostWebviewFramePage();
const script = /<script>([\s\S]*?)<\/script>/.exec(page)?.[1] ?? '';
expect(script).toContain("postMessage({type:'codeman:webview-lost',path:path},'*')");
const { createHash } = await import('node:crypto');
const hash = createHash('sha256').update(script, 'utf8').digest('base64');
expect(LOST_FRAME_PAGE_CSP).toContain(`'sha256-${hash}'`);
expect(LOST_FRAME_PAGE_CSP).toContain("default-src 'none'");
// The page must never carry a Referer that would leak anything about the tab.
expect(page).toContain('name="referrer" content="no-referrer"');
});
});