Merge pull request #402 from shenlvkang-collab/pr/webview-route-masking

fix(webview): let a proxied single-page app route on its own path, and recover a frame that reloads
This commit is contained in:
Codeman maintainer
2026-09-14 23:38:54 +02:00
9 changed files with 424 additions and 10 deletions
+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 { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.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';
// 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;
}
/**
* 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.
*
@@ -302,6 +332,8 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
done();
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;
@@ -439,6 +471,8 @@ function registerMultiUserAuthHook(
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
// than re-deriving it from a request that carries no credentials.
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;
+49 -4
View File
@@ -194,6 +194,7 @@ Object.assign(CodemanApp.prototype, {
this._webviewFrameLru = this._webviewFrameLru || [];
await this.refreshWebviews();
this._installWebviewLostListener();
// Restore the previously open web tabs (per device: which dashboards you keep
// open is a workspace-layout choice, not something to sync across machines).
@@ -231,6 +232,48 @@ Object.assign(CodemanApp.prototype, {
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() {
try {
localStorage.setItem('codeman-webview-order', JSON.stringify(this.webviewOrder || []));
@@ -328,13 +371,15 @@ Object.assign(CodemanApp.prototype, {
if (data.webview) this.webviews.set(id, data.webview);
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) {
// 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;
}
this._mountWebviewFrame(id, src, data.webview || webview, { navigate: !!path });
this._mountWebviewFrame(id, src, data.webview || webview, { navigate: path !== null });
this.activeWebviewId = id;
this.hideWelcome?.();
document.querySelector('.main')?.classList.add('webview-active');
+10
View File
@@ -181,6 +181,7 @@ import {
registerTabLayoutRoutes,
tryWebviewRefererFallback,
} from './routes/index.js';
import { isLostWebviewFrameNavigation, lostWebviewFramePage, LOST_FRAME_PAGE_CSP } from './webview-proxy.js';
import { CronService } from '../cron/cron-service.js';
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
// genuinely unknown `/api` paths still get the envelope below.
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')) {
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.
*/
import { createHash } from 'node:crypto';
import { WEBVIEW_PROXY_PREFIX } from '../config/webview-limits.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.
return `<script>(function(){try{
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){
try{
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);
};
}
['WebSocket','EventSource'].forEach(function(k){
['WebSocket','EventSource','Worker','SharedWorker'].forEach(function(k){
var C=window[k];if(!C)return;
function W(u,p){return p===undefined?new C(rw(u)):new C(rw(u),p);}
W.prototype=C.prototype;
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(function(s){if(s in C)W[s]=C[s];});
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'];
function rwSet(v){
try{
@@ -680,3 +707,60 @@ export function upstreamWebSocketUrl(target: URL): string {
ws.protocol = ws.protocol === 'https:' ? 'wss:' : 'ws:';
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>`
);
}