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
+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);
});
});
/**
* 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>;
_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);
});
});
+97
View File
@@ -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"');
});
});