mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>`
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user