Files
Codeman/test/webview-loopback-links.test.ts
T
Codeman maintainer d9364f52e1 fix(webview): refuse a backslash or tab-led recovery path, which the URL parser reads as an origin
The lost-frame handler in webview-tabs.js remounts a web-tab frame at the path
the frame reports it lost. It promised "path only, never an origin" and collapsed
a leading run of slashes so `//host/x` could not jump the frame off the proxy,
but it left two spellings through that the WHATWG URL parser treats the same way:
a backslash, which is read as `/` for http(s) schemes, and an ASCII tab or
newline, which the parser deletes before it looks at anything, so `/\host/x` and
`/<tab>/host/x` both resolve to `https://host/x`. That mattered only in
direct-mode tabs, where `POST /api/webviews/:id/open` returns no embedUrl and the
recovered path is resolved with `new URL(path, src)` straight into the frame's
src; a page in such a tab could remount its own frame on a foreign origin.

Not an escalation (the page can already navigate itself anywhere, and the remount
carries no Codeman-origin access), but the comment did not hold and the existing
test only covered the form that already worked. The handler now strips tab, CR
and LF, collapses any leading run of `/` or `\` to one `/`, and refuses whatever
still opens a second separator. The new test drives the reachable direct-mode
branch with all four spellings and fails without the fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 23:38:54 +02:00

416 lines
20 KiB
TypeScript

/**
* @fileoverview Loopback links open through a proxied web tab (webview-tabs.js).
*
* An agent prints `http://localhost:5173/` and the user taps it on a phone. The
* browser there can never reach the Codeman box's loopback, so the link was a
* guaranteed connection error — while the web-tab proxy fetches from the server,
* where it works. Pinned here:
*
* 1. The decision: only http(s) on a loopback host, and only when the page
* itself is not on that host. A LAN/tailnet address stays a direct open.
* 2. A saved proxied dashboard on the same origin is reused, with the deep
* path appended to the minted proxy prefix; a mounted frame is navigated,
* not torn down.
* 3. An unknown origin is saved under its host:port and opened.
* 4. The terminal link provider and the response viewer consult the hook
* before their own opening path.
*
* Same in-test JSDOM boot as webview-menu-rows.test.ts (no per-file env).
*/
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
const CONSTANTS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
const WEBVIEW_TABS = readFileSync(new URL('../src/web/public/webview-tabs.js', import.meta.url), 'utf-8');
const TERMINAL_UI = readFileSync(new URL('../src/web/public/terminal-ui.js', import.meta.url), 'utf-8');
const APP_JS = readFileSync(new URL('../src/web/public/app.js', import.meta.url), 'utf-8');
type Webview = { id: string; name: string; url: string; embedMode?: string; managed?: string };
interface AppLike {
webviews: Map<string, Webview>;
webviewOrder: string[];
activeWebviewId: string | null;
renderSessionTabs(): void;
refreshWebviews(): Promise<void>;
openLinkThroughWebTabIfLoopback(url: string): boolean;
openUrlInWebTab(url: string): Promise<void>;
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;
}
function boot(pageUrl = 'http://192.168.1.135:8095/') {
const dom = new JSDOM(
`<!doctype html><body><div class="main"></div><div id="sessionTabs"></div><div id="webviewLayer"></div></body>`,
{ url: pageUrl, runScripts: 'outside-only' }
);
const win = dom.window as unknown as Window &
typeof globalThis & { app: AppLike; CodemanApp: new () => AppLike; CodemanWebviewLinks: WebviewLinks };
(win as unknown as { eval: (s: string) => void }).eval(
[
'window.CodemanApp = class CodemanApp {};',
'window.CodemanBase = { url: (p) => p };',
'if (!window.CSS) window.CSS = { escape: (s) => s };',
'window.requestAnimationFrame = (fn) => { fn(); return 1; };',
CONSTANTS,
WEBVIEW_TABS,
].join('\n')
);
const calls: Array<{ path: string; method: string; body?: unknown }> = [];
const app = new win.CodemanApp();
app.webviews = new Map([
['dev', { id: 'dev', name: 'localhost:5173', url: 'http://localhost:5173/' }],
['direct', { id: 'direct', name: 'Direct', url: 'https://localhost:9443/', embedMode: 'direct' }],
]);
app.webviewOrder = [];
app.activeWebviewId = null;
app.renderSessionTabs = () => {};
app._updateActiveWebviewTab = () => {};
app.refreshWebviews = async () => {};
app._apiJson = async (path: string, opts: { method?: string; body?: unknown } = {}) => {
calls.push({ path, method: opts.method || 'GET', body: opts.body });
if (path === '/api/webviews' && opts.method === 'POST') {
const body = opts.body as { name: string; url: string };
const created = { id: 'new-id', name: body.name, url: body.url, embedMode: 'proxy' };
app.webviews.set(created.id, created);
return created;
}
const open = /^\/api\/webviews\/([^/]+)\/open$/.exec(path);
if (open) {
const id = decodeURIComponent(open[1]);
const webview = app.webviews.get(id);
if (!webview) return null;
return webview.embedMode === 'direct' ? { webview } : { webview, embedUrl: `/webview/cap-${id}/` };
}
return null;
};
win.app = app;
return { win, app, calls };
}
interface WebviewLinks {
isLoopbackHostname(host: string): boolean;
isOnBoxHostname(host: string): boolean;
webTabOriginKey(url: URL): string;
linkNeedsWebTabProxy(url: string, pageHostname: string): boolean;
}
const frameSrc = (win: Window, id: string) =>
(
win.document.querySelector(`.webview-frame[data-webview-id="${id}"] iframe`) as HTMLIFrameElement | null
)?.getAttribute('src');
describe('loopback link decision', () => {
const { win } = boot();
const links = win.CodemanWebviewLinks;
it('recognises every loopback spelling and nothing else', () => {
for (const host of ['localhost', 'LOCALHOST', '127.0.0.1', '127.1.2.3', '0.0.0.0', '[::1]', '::1']) {
expect(links.isLoopbackHostname(host), host).toBe(true);
}
for (const host of [
'192.168.1.135',
'10.9.0.4',
'172.16.0.2',
'box.ts.net',
'128.0.0.1',
'',
'localhost.example.com',
]) {
expect(links.isLoopbackHostname(host), host).toBe(false);
}
});
it('keeps *.localhost OUT of the auto-route set, because it is a DNS name an attacker can steer', () => {
// Every other member of the set is an address literal that can only mean
// this box. `evil.localhost` is not: on a resolver that does not synthesise
// *.localhost locally and has a search domain configured, it NXDOMAINs as
// absolute and is retried as `evil.localhost.<search domain>`. The link
// source is agent-written terminal output, so this set is the whole
// confinement on a tap that makes Codeman fetch a URL and persist it.
expect(links.isLoopbackHostname('app.localhost')).toBe(false);
expect(links.isLoopbackHostname('evil.localhost')).toBe(false);
expect(links.linkNeedsWebTabProxy('http://evil.localhost/', '192.168.1.135')).toBe(false);
// The PAGE-side test stays broader: a false positive there only ever
// DECLINES to proxy, leaving the caller's own direct open untouched.
expect(links.isOnBoxHostname('app.localhost')).toBe(true);
expect(links.linkNeedsWebTabProxy('http://localhost:5173/', 'app.localhost')).toBe(false);
});
it('keys a dashboard per dev server, not per host spelling', () => {
const key = (u: string) => links.webTabOriginKey(new URL(u));
expect(key('http://localhost:5173/')).toBe(key('http://127.0.0.1:5173/'));
expect(key('http://localhost:5173/')).not.toBe(key('http://localhost:5174/'));
expect(key('http://localhost:5173/')).not.toBe(key('https://localhost:5173/'));
expect(key('http://box.ts.net:3000/')).toBe('http://box.ts.net:3000');
});
it('proxies a loopback http(s) link only when the page is not on that box', () => {
expect(links.linkNeedsWebTabProxy('http://localhost:5173/', '192.168.1.135')).toBe(true);
expect(links.linkNeedsWebTabProxy('https://127.0.0.1:8443/x?y=1', 'box.ts.net')).toBe(true);
// On the box itself the browser reaches localhost directly.
expect(links.linkNeedsWebTabProxy('http://localhost:5173/', 'localhost')).toBe(false);
expect(links.linkNeedsWebTabProxy('http://localhost:5173/', '127.0.0.1')).toBe(false);
// A LAN address may be reachable from the device; leave it direct.
expect(links.linkNeedsWebTabProxy('http://192.168.1.135:3000/', '192.168.1.135')).toBe(false);
expect(links.linkNeedsWebTabProxy('http://10.9.0.4:8095/', '192.168.1.135')).toBe(false);
// Not a web URL at all.
expect(links.linkNeedsWebTabProxy('ftp://localhost/', '192.168.1.135')).toBe(false);
expect(links.linkNeedsWebTabProxy('not a url', '192.168.1.135')).toBe(false);
expect(links.linkNeedsWebTabProxy('', '192.168.1.135')).toBe(false);
});
});
describe('openLinkThroughWebTabIfLoopback', () => {
it('reuses the saved proxied dashboard on that origin and opens the deep path', async () => {
const { win, app, calls } = boot();
expect(app.openLinkThroughWebTabIfLoopback('http://localhost:5173/pages/report?tab=2#top')).toBe(true);
await vi.waitFor(() => expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/pages/report?tab=2#top'));
expect(calls.some((c) => c.path === '/api/webviews' && c.method === 'POST')).toBe(false);
expect(app.activeWebviewId).toBe('dev');
expect(app.webviewOrder).toEqual(['dev']);
});
it('navigates an already-mounted frame instead of remounting it', async () => {
const { win, app } = boot();
await app.openWebview('dev');
const first = win.document.querySelector('.webview-frame[data-webview-id="dev"] iframe');
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/');
await app.openUrlInWebTab('http://localhost:5173/other');
expect(win.document.querySelector('.webview-frame[data-webview-id="dev"] iframe')).toBe(first);
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/other');
expect(win.document.querySelectorAll('.webview-frame').length).toBe(1);
});
it('navigates an already-mounted frame back to the origin ROOT, which used to do nothing', async () => {
// openUrlInWebTab used to flatten '/' to '', and openWebview reads an empty
// path as "no deep link", so it mounted with navigate:false and an open
// frame stayed on whatever page it was showing. Deep links navigated; a tap
// on the bare origin silently did not.
const { win, app } = boot();
await app.openUrlInWebTab('http://localhost:5173/deep/page?a=1');
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/deep/page?a=1');
await app.openUrlInWebTab('http://localhost:5173/');
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/');
expect(win.document.querySelectorAll('.webview-frame').length).toBe(1);
});
it('reuses one dashboard across host spellings of the same dev server', async () => {
const { win, app, calls } = boot();
// The saved dashboard is http://localhost:5173/; a 127.0.0.1 link to the
// same port is the same server and must not mint a second tab.
await app.openUrlInWebTab('http://127.0.0.1:5173/status');
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/status');
expect(calls.find((c) => c.method === 'POST' && c.path === '/api/webviews')).toBeUndefined();
expect(win.document.querySelectorAll('.webview-frame').length).toBe(1);
});
it('waits for an in-flight webview load instead of POSTing a duplicate record', async () => {
// initWebviews() assigns a truthy EMPTY map synchronously and only then
// awaits GET /api/webviews, so "is this.webviews set" answered "is it
// loaded" wrongly: a tap inside that round trip found nothing to reuse and
// saved a second dashboard for an origin that already existed server-side.
const { win, app, calls } = boot();
const loaded = app.webviews;
app.webviews = new Map();
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
(app as unknown as { _webviewsRefresh: Promise<void> })._webviewsRefresh = gate.then(() => {
app.webviews = loaded;
});
const tap = app.openUrlInWebTab('http://localhost:5173/late');
release();
await tap;
expect(calls.find((c) => c.method === 'POST' && c.path === '/api/webviews')).toBeUndefined();
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/late');
});
it('saves an unknown origin under its host:port, then opens it', async () => {
const { win, app, calls } = boot();
expect(app.openLinkThroughWebTabIfLoopback('http://127.0.0.1:3000/')).toBe(true);
await vi.waitFor(() => expect(frameSrc(win, 'new-id')).toBe('/webview/cap-new-id/'));
const post = calls.find((c) => c.path === '/api/webviews' && c.method === 'POST');
expect(post?.body).toEqual({
name: '127.0.0.1:3000',
url: 'http://127.0.0.1:3000/',
embedMode: 'proxy',
trusted: false,
});
});
it("does not reuse a TRUSTED dashboard, whose frame runs on Codeman's own origin", async () => {
// A trusted webview is mounted with `allow-same-origin`. The link being
// followed came from agent output, so auto-reusing that frame would let an
// agent-chosen path be opened inside a privileged origin on one tap. A fresh
// sandboxed record is saved instead.
const { win, app, calls } = boot();
app.webviews.set('trusted-dev', {
id: 'trusted-dev',
name: 'trusted',
url: 'http://localhost:5173/',
embedMode: 'proxy',
trusted: true,
} as never);
app.webviews.delete('dev');
await app.openUrlInWebTab('http://localhost:5173/admin');
const post = calls.find((c) => c.method === 'POST' && c.path === '/api/webviews');
expect(post, 'a trusted dashboard must not be reused for a tapped link').toBeTruthy();
expect(post?.body).toMatchObject({ url: 'http://localhost:5173/', trusted: false });
expect(frameSrc(win, 'trusted-dev')).toBeFalsy();
});
it('does not reuse a direct-mode dashboard, which cannot show a loopback page from elsewhere', async () => {
const { win, app, calls } = boot();
expect(app.openLinkThroughWebTabIfLoopback('https://localhost:9443/admin')).toBe(true);
await vi.waitFor(() => expect(frameSrc(win, 'new-id')).toBe('/webview/cap-new-id/admin'));
expect(calls.find((c) => c.method === 'POST' && c.path === '/api/webviews')?.body).toMatchObject({
url: 'https://localhost:9443/',
});
});
it('leaves a reachable link alone so the caller opens it directly', () => {
const { app, calls } = boot();
expect(app.openLinkThroughWebTabIfLoopback('http://192.168.1.135:3000/')).toBe(false);
expect(app.openLinkThroughWebTabIfLoopback('https://example.com/')).toBe(false);
expect(calls).toHaveLength(0);
});
it('opens loopback links directly when the page itself is on the box', () => {
const { app, calls } = boot('http://localhost:8095/');
expect(app.openLinkThroughWebTabIfLoopback('http://localhost:5173/')).toBe(false);
expect(calls).toHaveLength(0);
});
});
describe('callers consult the hook first', () => {
it('terminal URL links try the web tab before window.open', () => {
const activate = TERMINAL_UI.indexOf('activate(_event, text) {');
expect(activate).toBeGreaterThan(-1);
const body = TERMINAL_UI.slice(activate, TERMINAL_UI.indexOf('},', activate));
expect(body.indexOf('openLinkThroughWebTabIfLoopback?.(text)')).toBeGreaterThan(-1);
expect(body.indexOf('openLinkThroughWebTabIfLoopback?.(text)')).toBeLessThan(body.indexOf('window.open('));
});
it('response viewer links route through the hook and keep the file-path handler first', () => {
const bind = APP_JS.indexOf('_bindResponseViewerInteractions(body) {');
const section = APP_JS.slice(bind, bind + 2500);
const pathHandler = section.indexOf("closest('a.rv-path')");
const urlHandler = section.indexOf("closest('a[href]')");
expect(pathHandler).toBeGreaterThan(-1);
expect(urlHandler).toBeGreaterThan(pathHandler);
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);
});
/**
* The proxied form above cannot leave the prefix whatever the path says. A
* DIRECT-mode tab is the reachable case: `POST /open` returns no embedUrl, so
* the recovered path is resolved with `new URL(path, src)` and becomes the
* frame's src. The WHATWG parser reads a backslash as `/` for http(s), and
* deletes ASCII tab and newline before parsing, so `/\host/x` and `/<tab>/host/x`
* both mean `//host/x` there: a page in such a tab could remount its own frame
* on a foreign origin. Not an escalation (the page can already navigate itself
* anywhere), but the handler promises "path only, never an origin".
*/
it('keeps a direct-mode frame on its own origin whatever separator the path opens with', async () => {
const { win, app } = boot();
app._installWebviewLostListener();
await app.openWebview('direct');
expect(frameSrc(win, 'direct')).toBe('https://localhost:9443/');
const attempts = ['/\\evil.example/x', '\\\\evil.example/x', '/\t/evil.example/x', '/\n/evil.example/x'];
for (const path of attempts) {
lost(win, frameOf(win, 'direct').contentWindow, path);
await vi.waitFor(() => expect(frameSrc(win, 'direct')).toBe('https://localhost:9443/evil.example/x'));
// Reset for the next spelling so the assertion above cannot pass on a stale
// src; through openWebview rather than another lost message, which is
// capped at five per minute per frame.
await app.openWebview('direct', { path: '/' });
expect(frameSrc(win, 'direct')).toBe('https://localhost:9443/');
}
});
});