/** * @fileoverview Pure helpers for the web-tab reverse proxy. No I/O, no Fastify. * * The proxy exists because an iframe pointing straight at a dashboard cannot work * in the deployment that matters: prod serves HTTPS (behind `tailscale serve`), so * a plain-HTTP dashboard is hard-blocked as mixed content; many dashboards also * refuse framing outright via `X-Frame-Options` / `frame-ancestors`; and Codeman's * own CSP (`default-src 'self'`) blocks cross-origin frames anyway. Serving the * dashboard through Codeman's own origin dissolves all three at once, and keeps * the production CSP byte-for-byte unchanged because `/webview/...` is `'self'`. * * ## Origin-scoped, not path-scoped * * `/webview//x/y` always maps to `/x/y`, never to * `/x/y`. Dashboards reference assets with * root-absolute paths (`/public/build/app.js`), so origin-scoping is the only * mapping under which those resolve. The saved URL's own path+query is used for * exactly one thing: what `/webview//` itself serves (the landing page). * * ## What gets rewritten, and why each one is load-bearing * * - `x-frame-options` / CSP `frame-ancestors`: dropped, else the browser refuses * to render the frame. This is the whole point of the proxy. * - `content-encoding` / `content-length`: dropped, because undici's `fetch` * already decoded the body. Forwarding them makes the browser try to gunzip * plaintext. * - `authorization` + the `codeman_session` cookie: stripped on the way OUT. In * trusted mode the iframe is same-origin, so the browser attaches Codeman's own * Basic-auth header and session cookie to every proxied request. Forwarding * those would hand CODEMAN_PASSWORD to the dashboard. * - `Location` and `Set-Cookie`: remapped into the proxy path, else a redirect or * a login cookie escapes the prefix and lands on Codeman's root. * - `` + root-absolute attribute rewriting: relative and `/`-rooted * URLs in the HTML resolve back through the proxy instead of hitting Codeman. * * No `X-Forwarded-*` is sent deliberately: apps that honor it generate absolute * URLs against Codeman's root, which would bypass the `/webview//` prefix * that everything else here works to preserve. */ import { WEBVIEW_PROXY_PREFIX } from '../config/webview-limits.js'; /** Headers that are per-connection and must never be relayed in either direction. */ const HOP_BY_HOP = new Set([ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade', ]); /** * Request headers dropped on the way to the upstream. `authorization` and `cookie` * carry Codeman's own credentials on a same-origin (trusted) frame; `host`, * `content-length` and `accept-encoding` are recomputed by undici. */ const DROP_REQUEST_HEADERS = new Set([ ...HOP_BY_HOP, 'host', 'content-length', 'accept-encoding', 'authorization', 'cookie', 'origin', 'referer', 'x-codeman-hook-secret', ]); /** Response headers dropped on the way back to the browser. */ const DROP_RESPONSE_HEADERS = new Set([ ...HOP_BY_HOP, 'content-encoding', 'content-length', 'x-frame-options', 'content-security-policy-report-only', // Cross-origin isolation headers describe the UPSTREAM's origin policy; applied // to a frame on Codeman's origin they only produce blocked-resource surprises. 'cross-origin-opener-policy', 'cross-origin-embedder-policy', 'cross-origin-resource-policy', 'set-cookie', 'location', 'content-security-policy', // The upstream's CORS answer describes ITS origin; the frame asking is // opaque-origin on ours, so ours must replace it (see buildProxyCorsHeaders). 'access-control-allow-origin', 'access-control-allow-credentials', 'access-control-allow-methods', 'access-control-allow-headers', 'access-control-expose-headers', 'access-control-max-age', ]); /** The same-origin path prefix an iframe loads for a given capability. */ export function proxyPrefixFor(capability: string): string { return `${WEBVIEW_PROXY_PREFIX}/${capability}/`; } /** * Parse and validate a user-supplied dashboard URL. * * Rejects everything that is not plain `http:`/`https:`, anything carrying * embedded credentials (they would be silently forwarded and logged), and * anything without a hostname. Returns the normalized `URL` or null. */ export function parseWebviewUrl(raw: string): URL | null { if (typeof raw !== 'string' || raw.trim() === '') return null; let url: URL; try { url = new URL(raw.trim()); } catch { return null; } if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; if (url.username !== '' || url.password !== '') return null; if (!url.hostname) return null; return url; } /** Convenience predicate for Zod refinements. */ export function isValidWebviewUrl(raw: string): boolean { return parseWebviewUrl(raw) !== null; } /** * Map a proxy request path to its upstream URL. * * `wildcard` is Fastify's `*` param: the path after `/webview//`, without a * leading slash. An empty wildcard means the landing page, which is the saved * URL's own path and query. * * Returns null when the result would escape the upstream origin (a `..` chain, a * protocol-relative `//evil.com` wildcard, or an absolute URL smuggled into the * path). That check is what keeps this from being an open proxy. */ export function resolveUpstreamUrl(savedUrl: string, wildcard: string, search: string): URL | null { const base = parseWebviewUrl(savedUrl); if (!base) return null; if (wildcard === '' || wildcard === '/') { const landing = new URL(base.pathname + (search || base.search), base.origin); return landing.origin === base.origin ? landing : null; } // A wildcard starting with `//` would parse as protocol-relative and jump host. const path = wildcard.startsWith('/') ? wildcard : `/${wildcard}`; if (path.startsWith('//')) return null; let target: URL; try { target = new URL(path + (search || ''), base.origin); } catch { return null; } return target.origin === base.origin ? target : null; } /** Extract the capability from a `/webview//...` pathname, or null. */ export function capabilityFromProxyPath(pathname: string): string | null { if (typeof pathname !== 'string') return null; const prefix = `${WEBVIEW_PROXY_PREFIX}/`; if (!pathname.startsWith(prefix)) return null; const rest = pathname.slice(prefix.length); const slash = rest.indexOf('/'); const cap = slash === -1 ? rest : rest.slice(0, slash); return /^[A-Za-z0-9_-]{16,128}$/.test(cap) ? cap : null; } /** * Extract the capability a `Referer` belongs to. Backs the 404 fallback that * catches root-absolute asset requests (`/static/app.js`) which `` cannot fix. */ export function capabilityFromReferer(referer: string | undefined): string | null { if (!referer) return null; try { return capabilityFromProxyPath(new URL(referer).pathname); } catch { return null; } } /** Remove the `frame-ancestors` directive from a CSP, preserving the rest. */ export function stripFrameAncestors(csp: string): string { return csp .split(';') .map((d) => d.trim()) .filter((d) => d !== '' && !/^frame-ancestors\b/i.test(d)) .join('; '); } /** The `frame-ancestors` directive value from a CSP, or undefined. */ export function extractFrameAncestors(csp: string | undefined): string | undefined { if (!csp) return undefined; for (const directive of csp.split(';')) { const trimmed = directive.trim(); if (/^frame-ancestors\b/i.test(trimmed)) { return trimmed.slice('frame-ancestors'.length).trim(); } } return undefined; } /** * Whether a target permits being framed by a different origin, judged from its * `X-Frame-Options` and CSP. Used only to recommend proxy vs direct mode in the * editor; the proxy path works either way. */ export function isFramableCrossOrigin(xFrameOptions: string | undefined, csp: string | undefined): boolean { const xfo = xFrameOptions?.trim().toLowerCase(); if (xfo === 'deny' || xfo === 'sameorigin') return false; const ancestors = extractFrameAncestors(csp)?.toLowerCase(); if (ancestors === undefined) return true; if (ancestors.includes("'none'")) return false; // 'self' alone means same-origin only, which a cross-origin embed is not. if (ancestors === "'self'") return false; return ancestors.includes('*') || ancestors.includes('http'); } /** * Rewrite an upstream `Location` into the proxy path. * * Same-origin redirects (relative or absolute) are remapped so the browser stays * inside the frame. Cross-origin redirects are returned unchanged rather than * proxied: relaying them would turn this into an open proxy for any host the * upstream chooses to name. */ export function rewriteLocation(location: string, requestUrl: URL, capability: string): string { let resolved: URL; try { resolved = new URL(location, requestUrl); } catch { return location; } if (resolved.origin !== requestUrl.origin) return location; const suffix = resolved.pathname.replace(/^\//, ''); return `${proxyPrefixFor(capability)}${suffix}${resolved.search}${resolved.hash}`; } /** * Rewrite an upstream `Set-Cookie` so it applies to the proxy path only. * * `Domain` is dropped (the cookie now belongs to Codeman's host), `Path` is * rebased onto the proxy prefix so two dashboards cannot collide on a shared * cookie name, and `Secure` is dropped when Codeman itself is serving plain HTTP * in dev, where a Secure cookie would simply be discarded. */ export function rewriteSetCookie(cookie: string, capability: string, secureContext: boolean): string { const parts = cookie.split(';'); const out: string[] = [parts[0]]; let sawPath = false; for (const raw of parts.slice(1)) { const attr = raw.trim(); const lower = attr.toLowerCase(); if (lower.startsWith('domain=')) continue; if (lower === 'secure' && !secureContext) continue; if (lower.startsWith('path=')) { sawPath = true; const value = attr.slice('path='.length); const suffix = value.replace(/^\//, ''); out.push(`Path=${proxyPrefixFor(capability)}${suffix}`); continue; } out.push(attr); } if (!sawPath) out.push(`Path=${proxyPrefixFor(capability)}`); return out.join('; '); } /** Drop named cookies from a `Cookie` request header, returning undefined if none remain. */ export function filterCookieHeader(cookie: string | undefined, drop: string[]): string | undefined { if (!cookie) return undefined; const dropSet = new Set(drop.map((n) => n.toLowerCase())); const kept = cookie .split(';') .map((c) => c.trim()) .filter((c) => c !== '' && !dropSet.has(c.slice(0, c.indexOf('=')).trim().toLowerCase())); return kept.length > 0 ? kept.join('; ') : undefined; } /** Build the header set sent upstream, from the browser's request headers. */ export function buildUpstreamRequestHeaders( incoming: Record, upstream: URL, opts: { forwardCookies: boolean; sessionCookieName: string; refererPath?: string } ): Record { const headers: Record = {}; for (const [key, value] of Object.entries(incoming)) { const lower = key.toLowerCase(); if (DROP_REQUEST_HEADERS.has(lower)) continue; if (value === undefined) continue; headers[lower] = Array.isArray(value) ? value.join(', ') : value; } if (opts.forwardCookies) { const raw = incoming['cookie']; const cookie = filterCookieHeader(Array.isArray(raw) ? raw.join('; ') : raw, [opts.sessionCookieName]); if (cookie) headers['cookie'] = cookie; } // Present as if the browser were talking to the dashboard directly. Apps that // check Origin on writes (CSRF defenses) need this to match their own origin. headers['origin'] = upstream.origin; headers['referer'] = opts.refererPath ? new URL(opts.refererPath, upstream.origin).href : upstream.href; return headers; } /** * Build the response headers sent to the browser. * * Also returns the CSP to apply: the upstream's, minus `frame-ancestors`. Callers * MUST set (or explicitly clear) this, because `registerSecurityHeaders` has * already stamped Codeman's own `default-src 'self'` policy onto the reply, and * that policy would break virtually every dashboard. */ export function buildDownstreamResponseHeaders( upstreamHeaders: Iterable<[string, string]>, /** * Upstream `Set-Cookie` values, already separated. Passed in rather than read * from `upstreamHeaders` because iterating a `Headers` object JOINS duplicate * set-cookie values into one comma-separated string, which cannot be split back * apart reliably (Expires dates contain commas). Callers use * `response.headers.getSetCookie()`. */ setCookies: string[], capability: string, requestUrl: URL, secureContext: boolean ): { headers: Record; setCookie: string[]; csp: string | null } { const headers: Record = {}; let csp: string | null = null; for (const [key, value] of upstreamHeaders) { const lower = key.toLowerCase(); if (lower === 'content-security-policy') { const stripped = stripFrameAncestors(value); csp = stripped === '' ? null : stripped; continue; } if (lower === 'location') { headers['location'] = rewriteLocation(value, requestUrl, capability); continue; } if (DROP_RESPONSE_HEADERS.has(lower)) continue; headers[lower] = value; } const setCookie = setCookies.map((cookie) => rewriteSetCookie(cookie, capability, secureContext)); return { headers, setCookie, csp }; } /** * A tiny script injected at the top of every proxied document, rewriting * ROOT-ABSOLUTE URLs built at runtime so they stay inside the proxy prefix. * * `` only governs URLs the HTML parser resolves. A dashboard that calls * `fetch('/api/data')` bypasses it entirely and the request lands on Codeman's own * root, where it 404s. That is not a rare shape: it is how most dashboards talk to * their own backend, and it presents as the dashboard's own "Failed to fetch". * * The `Referer`-keyed 404 fallback catches some of these, but deliberately NOT * paths under `/api`, `/ws` or `/q` (widening it there would let a request-supplied * header skip auth on Codeman's own API). Rewriting inside the iframe removes the * whole class instead of trading security for it: the page never emits a * root-absolute request in the first place. * * ## Why the DOM sinks are patched too, not just fetch/XHR * * A dashboard that renders `container.innerHTML = ''` * or `img.src = '/api/slide?n=01'` produces exactly the same root-absolute request, * and NONE of the other layers can reach it: `` does not apply to * root-absolute URLs at all, and `rewriteHtml()` only ever sees the initial * document, not markup built later by page script. The visible symptom is very * specific and easy to misread: the dashboard's DATA loads (reads go through * `fetch`, which was already patched) while every IMAGE stays broken. So the same * `rw()` is applied to `innerHTML`/`outerHTML`/`insertAdjacentHTML`, to * `setAttribute`, and to the `src`/`href`/`srcset`/... property setters, with a * `MutationObserver` as a last net for any sink not patched above (that one costs a * wasted 404 per node, since the browser starts fetching on insert, so it is a net * and not the mechanism). * * Runs before any page script because it is injected immediately after ``. * Only same-origin, non-prefixed, root-absolute URLs are touched; relative URLs * (already handled by ``) and cross-origin URLs are passed through. Every * rewrite is idempotent, so a value that passes through two layers is unchanged by * the second. */ export function runtimeUrlShim(prefix: string): string { // Kept dependency-free and defensive: it runs inside a page we do not control, // and a throw here would break the dashboard rather than fix it. return ``; } /** * Inject `` plus the runtime URL shim, and rebase * root-absolute `src`/`href`/`action` attributes, which `` alone does not * affect. * * Three layers, because no single one is sufficient: `` for parser-resolved * relative URLs, attribute rewriting for root-absolute markup, and the shim for * URLs built at runtime. */ export function rewriteHtml(html: string, capability: string): string { const prefix = proxyPrefixFor(capability); // Fresh regexes per call: module-level /g patterns carry `lastIndex` between calls. const rebased = html .replace(/(\s(?:src|href|action)\s*=\s*")\/(?!\/)/gi, `$1${prefix}`) .replace(/(\s(?:src|href|action)\s*=\s*')\/(?!\/)/gi, `$1${prefix}`); // A page that ships its own keeps it (overriding it would break the // author's intent), but it STILL needs the shim, which is the layer that // catches runtime-built URLs. So only the base tag is conditional. const injected = (/`) + runtimeUrlShim(prefix); const headMatch = /]*>/i.exec(rebased); if (headMatch) { const at = headMatch.index + headMatch[0].length; return rebased.slice(0, at) + injected + rebased.slice(at); } const htmlMatch = /]*>/i.exec(rebased); if (htmlMatch) { const at = htmlMatch.index + htmlMatch[0].length; return rebased.slice(0, at) + injected + rebased.slice(at); } return injected + rebased; } /** * CORS headers for a proxied response. * * Non-obvious but load-bearing: a SANDBOXED iframe (no `allow-same-origin`) runs * in an OPAQUE origin, so every `fetch`/XHR it makes is a cross-origin request even * though the URL is on this very host, and the browser requires CORS headers to * hand back the response. Without this, a dashboard renders fine (script/css/img * loads are not CORS-checked) while every one of its API calls fails with an opaque * `net::ERR_FAILED` and the page shows its own "failed to load" state. `curl` * cannot reproduce it, because curl does not enforce CORS. * * The origin is echoed rather than `*` so credentialed requests still work in * trusted mode. `null` (the opaque-origin case) is echoed as-is, but WITHOUT * `allow-credentials`, which browsers reject in combination. * * This grants nothing extra: the URL is already gated by the capability, and only * a document that was handed the capability can construct these requests. */ export function buildProxyCorsHeaders(origin: string | undefined, requestedHeaders?: string): Record { if (!origin) return {}; const headers: Record = { 'access-control-allow-origin': origin, 'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS', 'access-control-allow-headers': requestedHeaders && requestedHeaders.trim() !== '' ? requestedHeaders : '*', 'access-control-expose-headers': '*', 'access-control-max-age': '600', vary: 'Origin', }; // `Access-Control-Allow-Credentials: true` alongside a `null` origin is rejected // by browsers; a sandboxed frame sends no credentials anyway. if (origin !== 'null' && origin !== '*') headers['access-control-allow-credentials'] = 'true'; return headers; } /** Whether a content-type identifies HTML worth rewriting. */ export function isHtmlContentType(contentType: string | undefined): boolean { if (!contentType) return false; const type = contentType.split(';')[0].trim().toLowerCase(); return type === 'text/html' || type === 'application/xhtml+xml'; } /** Map an upstream http(s) URL to its ws(s) equivalent for the WebSocket leg. */ export function upstreamWebSocketUrl(target: URL): string { const ws = new URL(target.href); ws.protocol = ws.protocol === 'https:' ? 'wss:' : 'ws:'; return ws.href; }