mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -226,3 +227,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