/**
* 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,
} 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
', 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