Files
Codeman/test/webview-proxy.test.ts
T
Codeman maintainer b34fcaf928 feat(web-tabs): open dashboard URLs as tabs beside agent sessions
Adds a "Web / URL" section to the Run dropdown. A saved URL renders as a tab in
the same strip as Claude/Codex/Gemini sessions, with the same Alt+1..9 numbering,
so Codeman is one mission control instead of Codeman plus a pile of browser tabs.

A webview is NOT a sixth SessionMode: no PTY, no tmux, no respawn, no idle
detection. It is a separate resource sharing only the tab strip and the main
content area, the same call that keeps Docker and remote-SSH as case overlays.

Dashboards are proxied through Codeman's own origin, because a direct iframe
fails three ways at once in the shipped deployment: prod serves HTTPS behind
tailscale serve, so http:// targets are hard-blocked as mixed content (with no
override at all on iOS Safari); Grafana/Portainer-class dashboards send
X-Frame-Options: DENY; and our own default-src 'self' CSP blocks cross-origin
frames. Proxying dissolves all three and leaves the production CSP byte-for-byte
unchanged, since /webview/... is already covered by 'self'. A useful side effect:
the fetch happens server-side, so a tailnet-only dashboard is reachable from a
phone that is not on the tailnet.

The proxy is not an API surface. It authenticates on a 192-bit capability in the
path (memory-only, rolling TTL, bound to the minting user, revoked on edit or
delete) and is correspondingly exempt from the cookie and Origin checks, because
a sandboxed iframe is opaque-origin: it sends no SameSite=lax cookie and its
writes arrive with Origin: null. The Host allowlist is never bypassed. A second
Referer-keyed form of the exemption exists for root-absolute assets and is fenced
to safe methods on non-/api, non-/ws, non-/q paths.

Iframes omit allow-same-origin unless a URL is explicitly marked trusted, since a
proxied page is served from Codeman's own origin and could otherwise read this
document and drive the agent-spawning API. Authorization and codeman_session are
stripped upstream in BOTH modes, so CODEMAN_PASSWORD cannot leak into a dashboard.

Two things only a real browser reveals, both presenting as the dashboard's own
"Failed to fetch" while the page itself renders fine:

- Runtime-built root-absolute URLs (fetch('/api/data')) escape <base href> and
  land on Codeman's root. Widening the Referer fallback into /api would trade
  security for it, so an injected shim patches fetch/XHR/WebSocket/EventSource
  inside the frame instead, removing the class rather than the guard.
- An opaque-origin document CORS-checks every request, including to the host it
  was served from. Script/css/img loads are not CORS-checked, which is why the
  page renders while its API calls die. The proxy now emits CORS headers and
  answers preflights itself. registerSecurityHeaders answered every OPTIONS with
  a bare 204 before routing, carrying no ACAO for Origin: null, so that
  short-circuit now exempts a valid capability.

Neither is reproducible with curl, which does not enforce CORS.

Also fixes a pre-existing bug found on the way: .toolbar has backdrop-filter,
making it a stacking context that trapped .run-mode-menu's z-index:1000, so
.welcome-overlay painted over the whole Run menu. With no session open, every
item in it (Claude Code included) was unclickable.

Verified end to end against a real tailnet dashboard: live data, WebSocket push,
no failed requests, and switching tabs does not reload the frame. 98 new tests
cover the pure rewrite helpers, the CORS helper, the shim's rewrite logic, route
CRUD, and every edge of the auth exemption.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:06:36 +02:00

494 lines
19 KiB
TypeScript

/**
* Pure helpers behind the web-tab reverse proxy (src/web/webview-proxy.ts).
*
* These cover the rewrites that make an un-embeddable dashboard embeddable, and
* the containment checks that keep the proxy from becoming an open relay.
*/
import { describe, it, expect } from 'vitest';
import {
buildDownstreamResponseHeaders,
buildProxyCorsHeaders,
buildUpstreamRequestHeaders,
capabilityFromProxyPath,
capabilityFromReferer,
extractFrameAncestors,
filterCookieHeader,
isFramableCrossOrigin,
isHtmlContentType,
isValidWebviewUrl,
parseWebviewUrl,
proxyPrefixFor,
resolveUpstreamUrl,
rewriteHtml,
rewriteLocation,
rewriteSetCookie,
runtimeUrlShim,
stripFrameAncestors,
upstreamWebSocketUrl,
} from '../src/web/webview-proxy.js';
const CAP = 'A'.repeat(32);
const PREFIX = `/webview/${CAP}/`;
describe('parseWebviewUrl', () => {
it('accepts plain http and https', () => {
expect(parseWebviewUrl('http://127.0.0.1:4000/')?.origin).toBe('http://127.0.0.1:4000');
expect(parseWebviewUrl('https://dash.example.com/grafana')?.origin).toBe('https://dash.example.com');
});
it('rejects non-http schemes', () => {
for (const url of ['javascript:alert(1)', 'file:///etc/passwd', 'data:text/html,x', 'ftp://host/x']) {
expect(parseWebviewUrl(url), url).toBeNull();
}
});
it('rejects embedded credentials, which would be forwarded and logged', () => {
expect(parseWebviewUrl('http://user:pass@host:4000/')).toBeNull();
expect(parseWebviewUrl('http://user@host:4000/')).toBeNull();
});
it('rejects garbage and empty input', () => {
expect(parseWebviewUrl('')).toBeNull();
expect(parseWebviewUrl('not a url')).toBeNull();
expect(isValidWebviewUrl('http://ok.example')).toBe(true);
});
});
describe('resolveUpstreamUrl', () => {
const saved = 'http://127.0.0.1:4000/grafana/d/abc?theme=dark';
it('serves the saved path+query for the landing page', () => {
expect(resolveUpstreamUrl(saved, '', '')?.href).toBe('http://127.0.0.1:4000/grafana/d/abc?theme=dark');
});
it('is ORIGIN-scoped, not path-scoped, so root-absolute assets resolve', () => {
// The saved /grafana/d/abc path must NOT be prepended, or /public/x.js 404s.
expect(resolveUpstreamUrl(saved, 'public/build/app.js', '')?.href).toBe(
'http://127.0.0.1:4000/public/build/app.js'
);
});
it('carries the query string through', () => {
expect(resolveUpstreamUrl(saved, 'api/data', '?from=now-6h')?.href).toBe(
'http://127.0.0.1:4000/api/data?from=now-6h'
);
});
it('refuses to leave the upstream origin', () => {
// Protocol-relative would jump host; traversal would climb out.
expect(resolveUpstreamUrl(saved, '/evil.com/x', '')?.origin).toBe('http://127.0.0.1:4000');
expect(resolveUpstreamUrl(saved, '//evil.com/x', '')).toBeNull();
const climbed = resolveUpstreamUrl(saved, '../../../../etc/passwd', '');
expect(climbed?.origin).toBe('http://127.0.0.1:4000');
});
it('returns null for an unusable saved url', () => {
expect(resolveUpstreamUrl('javascript:alert(1)', 'x', '')).toBeNull();
});
});
describe('capability extraction', () => {
it('reads the capability out of a proxy path', () => {
expect(capabilityFromProxyPath(`${PREFIX}static/app.js`)).toBe(CAP);
expect(capabilityFromProxyPath(PREFIX)).toBe(CAP);
expect(capabilityFromProxyPath(`/webview/${CAP}`)).toBe(CAP);
});
it('does not match a lookalike prefix', () => {
expect(capabilityFromProxyPath('/webviewfoo/bar')).toBeNull();
expect(capabilityFromProxyPath('/api/webviews')).toBeNull();
expect(capabilityFromProxyPath('/')).toBeNull();
});
it('rejects capabilities of implausible shape', () => {
expect(capabilityFromProxyPath('/webview/short/x')).toBeNull();
expect(capabilityFromProxyPath('/webview/has spaces here and more/x')).toBeNull();
expect(capabilityFromProxyPath('/webview/../../etc/x')).toBeNull();
});
it('reads it from a Referer for the root-absolute asset fallback', () => {
expect(capabilityFromReferer(`https://box.ts.net${PREFIX}page`)).toBe(CAP);
expect(capabilityFromReferer('https://box.ts.net/')).toBeNull();
expect(capabilityFromReferer('not a url')).toBeNull();
expect(capabilityFromReferer(undefined)).toBeNull();
});
});
describe('CSP handling', () => {
it('strips frame-ancestors and keeps every other directive', () => {
const csp = "default-src 'self'; frame-ancestors 'none'; script-src 'unsafe-inline'";
expect(stripFrameAncestors(csp)).toBe("default-src 'self'; script-src 'unsafe-inline'");
});
it('leaves a policy without frame-ancestors alone', () => {
expect(stripFrameAncestors("default-src 'self'")).toBe("default-src 'self'");
});
it('does not confuse a similarly-named directive', () => {
expect(stripFrameAncestors("frame-src 'self'; frame-ancestors 'none'")).toBe("frame-src 'self'");
});
it('extracts the directive value for the probe', () => {
expect(extractFrameAncestors("default-src 'self'; frame-ancestors https://a.com")).toBe('https://a.com');
expect(extractFrameAncestors("default-src 'self'")).toBeUndefined();
expect(extractFrameAncestors(undefined)).toBeUndefined();
});
});
describe('isFramableCrossOrigin', () => {
it('honours X-Frame-Options', () => {
expect(isFramableCrossOrigin('DENY', undefined)).toBe(false);
expect(isFramableCrossOrigin('sameorigin', undefined)).toBe(false);
expect(isFramableCrossOrigin(undefined, undefined)).toBe(true);
});
it("treats frame-ancestors 'none' and 'self' as not cross-origin framable", () => {
expect(isFramableCrossOrigin(undefined, "frame-ancestors 'none'")).toBe(false);
expect(isFramableCrossOrigin(undefined, "frame-ancestors 'self'")).toBe(false);
});
it('allows a wildcard or explicit host', () => {
expect(isFramableCrossOrigin(undefined, 'frame-ancestors *')).toBe(true);
expect(isFramableCrossOrigin(undefined, 'frame-ancestors https://codeman.example')).toBe(true);
});
});
describe('rewriteLocation', () => {
const requestUrl = new URL('http://127.0.0.1:4000/login');
it('maps a root-absolute redirect into the proxy prefix', () => {
expect(rewriteLocation('/dashboard?x=1', requestUrl, CAP)).toBe(`${PREFIX}dashboard?x=1`);
});
it('maps a same-origin absolute redirect', () => {
expect(rewriteLocation('http://127.0.0.1:4000/home', requestUrl, CAP)).toBe(`${PREFIX}home`);
});
it('leaves a CROSS-origin redirect alone rather than relaying it', () => {
// Relaying would make this an open proxy for any host the upstream names.
expect(rewriteLocation('https://evil.example/x', requestUrl, CAP)).toBe('https://evil.example/x');
});
it('preserves the hash', () => {
expect(rewriteLocation('/panel#row2', requestUrl, CAP)).toBe(`${PREFIX}panel#row2`);
});
});
describe('rewriteSetCookie', () => {
it('rebases Path onto the proxy prefix and drops Domain', () => {
const out = rewriteSetCookie('sid=abc; Path=/; Domain=dash.local; HttpOnly', CAP, true);
expect(out).toContain('sid=abc');
expect(out).toContain(`Path=${PREFIX}`);
expect(out).not.toContain('Domain');
expect(out).toContain('HttpOnly');
});
it('adds a scoped Path when the upstream sent none', () => {
expect(rewriteSetCookie('sid=abc; HttpOnly', CAP, true)).toContain(`Path=${PREFIX}`);
});
it('drops Secure when Codeman itself is serving plain HTTP', () => {
// A Secure cookie over http is silently discarded by the browser.
expect(rewriteSetCookie('sid=abc; Path=/; Secure', CAP, false)).not.toMatch(/secure/i);
expect(rewriteSetCookie('sid=abc; Path=/; Secure', CAP, true)).toMatch(/Secure/);
});
it('keeps a nested upstream path under the prefix', () => {
expect(rewriteSetCookie('sid=abc; Path=/admin', CAP, true)).toContain(`Path=${PREFIX}admin`);
});
});
describe('filterCookieHeader', () => {
it("removes Codeman's own session cookie and keeps the dashboard's", () => {
expect(filterCookieHeader('codeman_session=SECRET; dash=1; other=2', ['codeman_session'])).toBe('dash=1; other=2');
});
it('returns undefined when nothing survives', () => {
expect(filterCookieHeader('codeman_session=SECRET', ['codeman_session'])).toBeUndefined();
expect(filterCookieHeader(undefined, ['codeman_session'])).toBeUndefined();
});
});
describe('buildUpstreamRequestHeaders', () => {
const upstream = new URL('http://127.0.0.1:4000/panel');
it('NEVER forwards Codeman credentials to the dashboard', () => {
const headers = buildUpstreamRequestHeaders(
{ authorization: 'Basic CODEMANCREDS', cookie: 'codeman_session=SECRET; dash=1', accept: '*/*' },
upstream,
{ forwardCookies: false, sessionCookieName: 'codeman_session' }
);
expect(headers.authorization).toBeUndefined();
expect(headers.cookie).toBeUndefined();
expect(headers.accept).toBe('*/*');
});
it('forwards the dashboard cookies but strips the session cookie in trusted mode', () => {
const headers = buildUpstreamRequestHeaders({ cookie: 'codeman_session=SECRET; dash=1' }, upstream, {
forwardCookies: true,
sessionCookieName: 'codeman_session',
});
expect(headers.cookie).toBe('dash=1');
expect(headers.authorization).toBeUndefined();
});
it('presents Origin/Referer as if the browser talked to the dashboard directly', () => {
const headers = buildUpstreamRequestHeaders({ origin: 'https://codeman.local' }, upstream, {
forwardCookies: false,
sessionCookieName: 'codeman_session',
});
expect(headers.origin).toBe('http://127.0.0.1:4000');
expect(headers.referer).toBe('http://127.0.0.1:4000/panel');
});
it('drops hop-by-hop and recomputed headers', () => {
const headers = buildUpstreamRequestHeaders(
{ host: 'codeman.local', connection: 'keep-alive', 'transfer-encoding': 'chunked', 'content-length': '5' },
upstream,
{ forwardCookies: false, sessionCookieName: 'codeman_session' }
);
expect(headers.host).toBeUndefined();
expect(headers.connection).toBeUndefined();
expect(headers['transfer-encoding']).toBeUndefined();
expect(headers['content-length']).toBeUndefined();
});
});
describe('buildDownstreamResponseHeaders', () => {
const requestUrl = new URL('http://127.0.0.1:4000/panel');
const build = (entries: Array<[string, string]>, cookies: string[] = []) =>
buildDownstreamResponseHeaders(entries, cookies, CAP, requestUrl, true);
it('strips the framing refusal, which is the whole point of the proxy', () => {
const { headers } = build([
['x-frame-options', 'DENY'],
['content-type', 'text/html'],
]);
expect(headers['x-frame-options']).toBeUndefined();
expect(headers['content-type']).toBe('text/html');
});
it('drops content-encoding/length because undici already decoded the body', () => {
// Forwarding these makes the browser try to gunzip plaintext.
const { headers } = build([
['content-encoding', 'gzip'],
['content-length', '1234'],
]);
expect(headers['content-encoding']).toBeUndefined();
expect(headers['content-length']).toBeUndefined();
});
it('returns the upstream CSP minus frame-ancestors, and null when there was none', () => {
expect(build([['content-security-policy', "default-src 'self'; frame-ancestors 'none'"]]).csp).toBe(
"default-src 'self'"
);
expect(build([['content-type', 'text/css']]).csp).toBeNull();
});
it('rewrites Location and Set-Cookie', () => {
const { headers, setCookie } = build([['location', '/next']], ['sid=1; Path=/']);
expect(headers.location).toBe(`${PREFIX}next`);
expect(setCookie).toHaveLength(1);
expect(setCookie[0]).toContain(`Path=${PREFIX}`);
});
});
describe('rewriteHtml', () => {
it('injects <base> immediately after <head>', () => {
const out = rewriteHtml('<html><head><title>x</title></head><body></body></html>', CAP);
expect(out).toContain(`<head><base href="${PREFIX}">`);
});
it('falls back to <html>, then to the very start, for malformed documents', () => {
expect(rewriteHtml('<html><body>hi</body></html>', CAP)).toContain(`<html><base href="${PREFIX}">`);
const bare = rewriteHtml('just text', CAP);
expect(bare.startsWith(`<base href="${PREFIX}">`)).toBe(true);
expect(bare.endsWith('just text')).toBe(true);
});
it('does not add a second <base> when the page already has one', () => {
const out = rewriteHtml('<html><head><base href="/x/"></head></html>', CAP);
expect(out.match(/<base/g)).toHaveLength(1);
});
it('still injects the runtime shim when the page ships its own <base>', () => {
// The shim is the only layer that catches runtime-built URLs, so an early
// return on an existing <base> would silently break those pages.
const out = rewriteHtml('<html><head><base href="/x/"></head></html>', CAP);
expect(out).toContain('<script>');
expect(out).toContain(PREFIX);
});
it('injects the shim into every rewritten document', () => {
expect(rewriteHtml('<html><head></head></html>', CAP)).toContain('<script>');
expect(rewriteHtml('just text', CAP)).toContain('<script>');
});
it('rebases root-absolute src/href/action, which <base> cannot fix', () => {
const out = rewriteHtml(
`<head></head><body><script src="/static/app.js"></script><link href='/s.css'><form action="/login"></form></body>`,
CAP
);
expect(out).toContain(`src="${PREFIX}static/app.js"`);
expect(out).toContain(`href='${PREFIX}s.css'`);
expect(out).toContain(`action="${PREFIX}login"`);
});
it('leaves protocol-relative and absolute URLs alone', () => {
const out = rewriteHtml('<head></head><script src="//cdn.example/x.js"></script><img src="https://a/b.png">', CAP);
expect(out).toContain('src="//cdn.example/x.js"');
expect(out).toContain('src="https://a/b.png"');
});
it('is stable across repeated calls (no shared regex lastIndex)', () => {
const html = '<head></head><script src="/a.js"></script>';
expect(rewriteHtml(html, CAP)).toBe(rewriteHtml(html, CAP));
});
});
describe('buildProxyCorsHeaders', () => {
it('echoes the opaque origin a sandboxed frame sends', () => {
// Without this the browser rejects every dashboard fetch with an opaque
// net::ERR_FAILED, while the page itself renders fine.
const h = buildProxyCorsHeaders('null');
expect(h['access-control-allow-origin']).toBe('null');
expect(h.vary).toBe('Origin');
});
it('omits allow-credentials for a null origin, which browsers reject together', () => {
expect(buildProxyCorsHeaders('null')['access-control-allow-credentials']).toBeUndefined();
});
it('allows credentials for a real origin (trusted mode)', () => {
const h = buildProxyCorsHeaders('https://codeman.local');
expect(h['access-control-allow-origin']).toBe('https://codeman.local');
expect(h['access-control-allow-credentials']).toBe('true');
});
it('echoes requested headers on a preflight', () => {
expect(buildProxyCorsHeaders('null', 'content-type, x-token')['access-control-allow-headers']).toBe(
'content-type, x-token'
);
expect(buildProxyCorsHeaders('null')['access-control-allow-headers']).toBe('*');
});
it('emits nothing when the request carries no Origin', () => {
expect(buildProxyCorsHeaders(undefined)).toEqual({});
});
});
describe('runtimeUrlShim', () => {
const shim = runtimeUrlShim(PREFIX);
const body = shim.replace(/^<script>/, '').replace(/<\/script>$/, '');
it('emits a parseable script', () => {
expect(shim.startsWith('<script>')).toBe(true);
expect(shim.endsWith('</script>')).toBe(true);
expect(() => new Function(body)).not.toThrow();
});
it('contains no bare </script> that would close the tag early', () => {
expect(/<\/script>/i.test(body)).toBe(false);
});
/**
* Execute the shim against a fake window and return the patched globals, so the
* rewrite logic is tested for real rather than by reading the source.
*/
function runShim(host = 'codeman.local') {
const calls: string[] = [];
const win: Record<string, unknown> = {
fetch: (input: unknown) => {
calls.push(String(typeof input === 'object' && input ? (input as { url: string }).url : input));
return Promise.resolve();
},
XMLHttpRequest: function () {} as unknown as { prototype: Record<string, unknown> },
WebSocket: class {
url: string;
constructor(u: string) {
this.url = u;
calls.push(u);
}
},
EventSource: class {
url: string;
constructor(u: string) {
this.url = u;
calls.push(u);
}
},
};
(win.XMLHttpRequest as { prototype: Record<string, unknown> }).prototype = {
open(_m: string, u: string) {
calls.push(u);
},
};
const location = { href: `https://${host}${PREFIX}page`, host };
new Function('window', 'location', 'URL', 'Request', `with (window) { ${body} }`)(win, location, URL, undefined);
return { win, calls };
}
it('rewrites a ROOT-ABSOLUTE fetch, the case <base> cannot reach', () => {
const { win, calls } = runShim();
(win.fetch as (u: string) => void)('/api/carousel/job?id=1');
expect(calls[0]).toBe(`${PREFIX}api/carousel/job?id=1`);
});
it('leaves relative URLs alone (<base> already handles them)', () => {
const { win, calls } = runShim();
(win.fetch as (u: string) => void)('api/data');
expect(calls[0]).toBe('api/data');
});
it('does not double-prefix an already-proxied URL', () => {
const { win, calls } = runShim();
(win.fetch as (u: string) => void)(`${PREFIX}api/data`);
expect(calls[0]).toBe(`${PREFIX}api/data`);
});
it('leaves cross-origin URLs alone', () => {
const { win, calls } = runShim();
(win.fetch as (u: string) => void)('https://cdn.example/lib.js');
expect(calls[0]).toBe('https://cdn.example/lib.js');
});
it('rewrites a same-origin ABSOLUTE URL built from location', () => {
const { win, calls } = runShim();
(win.fetch as (u: string) => void)('https://codeman.local/api/data');
expect(calls[0]).toBe(`https://codeman.local${PREFIX}api/data`);
});
it('patches XMLHttpRequest.open', () => {
const { win, calls } = runShim();
const xhr = win.XMLHttpRequest as { prototype: { open: (m: string, u: string) => void } };
xhr.prototype.open.call({}, 'GET', '/api/data');
expect(calls[0]).toBe(`${PREFIX}api/data`);
});
it('patches WebSocket and EventSource', () => {
const { win, calls } = runShim();
new (win.WebSocket as new (u: string) => unknown)('/live');
new (win.EventSource as new (u: string) => unknown)('/events');
expect(calls).toEqual([`${PREFIX}live`, `${PREFIX}events`]);
});
});
describe('misc helpers', () => {
it('identifies HTML content types, parameters included', () => {
expect(isHtmlContentType('text/html; charset=utf-8')).toBe(true);
expect(isHtmlContentType('application/xhtml+xml')).toBe(true);
expect(isHtmlContentType('application/json')).toBe(false);
expect(isHtmlContentType(undefined)).toBe(false);
});
it('maps http(s) to ws(s) for the socket leg', () => {
expect(upstreamWebSocketUrl(new URL('http://h:4000/live'))).toBe('ws://h:4000/live');
expect(upstreamWebSocketUrl(new URL('https://h/live'))).toBe('wss://h/live');
});
it('builds the iframe prefix', () => {
expect(proxyPrefixFor(CAP)).toBe(PREFIX);
});
});