/** * 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 { JSDOM } from 'jsdom'; import { buildDownstreamResponseHeaders, buildProxyCorsHeaders, buildUpstreamRequestHeaders, capabilityFromProxyPath, capabilityFromReferer, extractFrameAncestors, filterCookieHeader, isFramableCrossOrigin, isHtmlContentType, isValidWebviewUrl, parseWebviewUrl, proxyPrefixFor, resolveUpstreamUrl, rewriteHtml, rewriteLocation, rewriteSetCookie, runtimeUrlShim, stripFrameAncestors, upstreamWebSocketUrl, isLostWebviewFrameNavigation, lostWebviewFramePage, LOST_FRAME_PAGE_CSP, } 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 immediately after ', () => { const out = rewriteHtml('x', CAP); expect(out).toContain(``); }); it('falls back to , then to the very start, for malformed documents', () => { expect(rewriteHtml('hi', CAP)).toContain(``); const bare = rewriteHtml('just text', CAP); expect(bare.startsWith(``)).toBe(true); expect(bare.endsWith('just text')).toBe(true); }); it('does not add a second when the page already has one', () => { const out = rewriteHtml('', CAP); expect(out.match(/', () => { // The shim is the only layer that catches runtime-built URLs, so an early // return on an existing would silently break those pages. const out = rewriteHtml('', CAP); expect(out).toContain('
`, 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('', 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 = ''; 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(/^')).toBe(true); expect(() => new Function(body)).not.toThrow(); }); it('contains no bare 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 = { 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 }, 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 }).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 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 ( 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`]); }); }); /** * The DOM half of the shim, run in a real jsdom document rather than the fake * window above, because these patches ARE DOM behavior: what matters is the URL the * browser would end up requesting after `innerHTML = ...`, not whether some * function got wrapped. * * The bug being pinned: a dashboard rendering `` from * page script escapes `` (which never applies to root-absolute URLs) and * escapes `rewriteHtml()` (which only sees the initial document), so every image * 404s on Codeman's own root while the dashboard's fetch-driven data loads fine. * * Node environment on purpose, like test/markdown-sanitizer.test.ts: a per-file * jsdom environment directive would externalize node builtins under vite. The * directive is deliberately not written out here, even in prose: vitest scans * comments for it, and naming it flipped this whole file to the jsdom * environment while this comment claimed the opposite. */ describe('runtimeUrlShim DOM sinks', () => { const body = runtimeUrlShim(PREFIX) .replace(/^