mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39: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:
@@ -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
@@ -159,6 +159,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
|
||||
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.
|
||||
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
|
||||
wrong for same-host requests, but a sandboxed iframe has an *opaque* origin, so the
|
||||
@@ -172,10 +187,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,
|
||||
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.
|
||||
- **Root-absolute `location` navigation.** A dashboard that navigates itself with
|
||||
`location.href = '/login'` escapes the prefix, because `Location.href` is
|
||||
unforgeable and cannot be patched the way the other sinks are. A relative
|
||||
`location.href = 'login'` is fine (`<base>` covers it).
|
||||
- **Root-absolute `location` navigation is recovered, not prevented.** `Location`
|
||||
is unforgeable, so `location.href = '/login'` or `location.reload()` really does
|
||||
leave the prefix; the frame comes back through the recovery hop in layer 6 above,
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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>`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -236,3 +236,41 @@ describe('authenticated access is unaffected', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ interface AppLike {
|
||||
openWebview(id: string, options?: { path?: string }): Promise<void>;
|
||||
_apiJson(path: string, opts?: { method?: string; body?: unknown }): Promise<unknown>;
|
||||
_updateActiveWebviewTab(): void;
|
||||
_installWebviewLostListener(): void;
|
||||
showToast?: (msg: string, kind: string) => void;
|
||||
}
|
||||
|
||||
@@ -315,3 +316,73 @@ describe('callers consult the hook first', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
runtimeUrlShim,
|
||||
stripFrameAncestors,
|
||||
upstreamWebSocketUrl,
|
||||
isLostWebviewFrameNavigation,
|
||||
lostWebviewFramePage,
|
||||
LOST_FRAME_PAGE_CSP,
|
||||
} from '../src/web/webview-proxy.js';
|
||||
|
||||
const CAP = 'A'.repeat(32);
|
||||
@@ -720,3 +723,97 @@ describe('reverse-proxy base path', () => {
|
||||
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"');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user