mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
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:
co-authored by
Claude Fable 5.1
parent
d9eeb039db
commit
349a89ec3b
@@ -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;
|
||||
|
||||
|
||||
@@ -138,6 +138,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).
|
||||
@@ -167,6 +168,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 || []));
|
||||
@@ -264,13 +307,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