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:
@@ -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