/** * @fileoverview Web tabs: saved dashboard URLs, plus the reverse proxy that makes * them embeddable. * * Two distinct surfaces live here, and the split matters: * * 1. `/api/webviews/*`, ordinary authenticated CRUD, owner-scoped like every * other resource, returning the `ApiResponse` envelope. * 2. `/webview/:cap/*`, the proxy. NOT an API surface. It authenticates on an * unguessable capability in the path instead of Codeman's session cookie, and * is correspondingly exempt from the cookie and Origin checks in * `middleware/auth.ts`. See `src/webview-capabilities.ts` for why a cookie * cannot work here (sandboxed iframes are opaque-origin, so their requests are * cross-site and arrive with `Origin: null`). * * The proxy is registered inside an ENCAPSULATED plugin scope with its own * catch-all content-type parser. Fastify scopes parsers to the plugin that * registers them, which is what lets the proxy forward raw request bodies * upstream while the rest of the app keeps its JSON parsing (and, critically, * keeps `text/plain` raw, auto-parsing that was a real CSRF hole once). * * Endpoints: * GET /api/webviews * POST /api/webviews * PATCH /api/webviews/:id * DELETE /api/webviews/:id * POST /api/webviews/probe * POST /api/webviews/:id/open * ALL /webview/:cap/* (+ WebSocket upgrade on GET) */ import { randomUUID } from 'node:crypto'; import { Readable } from 'node:stream'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { WebSocket as WsClient } from 'ws'; import type { WebSocket } from 'ws'; import { getDataDir } from '../../config/instance.js'; import { MAX_LIVE_WEBVIEW_FRAMES, MAX_WEBVIEWS, MAX_WEBVIEW_HTML_REWRITE_BYTES, MAX_WEBVIEW_SOCKETS, WEBVIEW_PROBE_TIMEOUT_MS, WEBVIEW_PROXY_PREFIX, WEBVIEW_UPSTREAM_TIMEOUT_MS, WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS, } from '../../config/webview-limits.js'; import { readWebviews, writeWebviews } from '../../webview-store.js'; import { webviewCapabilities } from '../../webview-capabilities.js'; import { ApiErrorCode, createErrorResponse } from '../../types.js'; import type { Webview, WebviewOpenData, WebviewProbe } from '../../types.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { canAccessOwned, getAuthUser, ownerFor, parseBody } from '../route-helpers.js'; import { WebviewCreateSchema, WebviewProbeSchema, WebviewUpdateSchema } from '../schemas.js'; import { SseEvent } from '../sse-events.js'; import type { EventPort } from '../ports/index.js'; import { buildDownstreamResponseHeaders, buildProxyCorsHeaders, buildUpstreamRequestHeaders, capabilityFromReferer, extractFrameAncestors, isFramableCrossOrigin, isHtmlContentType, parseWebviewUrl, proxyPrefixFor, resolveUpstreamUrl, rewriteHtml, upstreamWebSocketUrl, } from '../webview-proxy.js'; /** * Resolved per call rather than captured at module load. `getDataDir()` reads * `CODEMAN_DATA_DIR` each time, so a lazy lookup keeps tests writing to a temp dir * instead of the developer's real `~/.codeman/webviews.json`. */ function configDir(): string { return getDataDir(); } /** Live proxied WebSockets per webview id, so one dashboard cannot exhaust the socket budget. */ const socketCounts = new Map(); interface ProxyParams { cap: string; '*'?: string; } /** Serialize webview mutations: read-modify-write on a shared JSON file otherwise races. */ let writeChain: Promise = Promise.resolve(); function withWebviews(fn: (list: Webview[]) => Promise | T): Promise { const next = writeChain.then(async () => { const list = await readWebviews(configDir()); return fn(list); }); // Keep the chain alive even if this link rejects, or every later write deadlocks. writeChain = next.catch(() => undefined); return next; } export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort): void { registerCrudRoutes(app, ctx); registerProxyRoutes(app); } // ───────────────────────────── CRUD ───────────────────────────── function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void { app.get('/api/webviews', async (req) => { const user = getAuthUser(req); const all = await readWebviews(configDir()); const webviews = all.filter((w) => canAccessOwned(user, w.owner)); return { success: true, data: { webviews, maxLiveFrames: MAX_LIVE_WEBVIEW_FRAMES } }; }); app.post('/api/webviews', async (req, reply) => { const input = parseBody(WebviewCreateSchema, req.body); const owner = ownerFor(req); const user = getAuthUser(req); const created = await withWebviews(async (list) => { const mine = list.filter((w) => canAccessOwned(user, w.owner)); if (mine.length >= MAX_WEBVIEWS) return null; const webview: Webview = { id: randomUUID(), name: input.name, url: input.url, icon: input.icon, // Proxy is the safe default: it is the only mode that works for a plain-HTTP // dashboard on an HTTPS Codeman, which is the common case. embedMode: input.embedMode ?? 'proxy', trusted: input.trusted ?? false, owner, createdAt: Date.now(), }; list.push(webview); await writeWebviews(configDir(), list); return webview; }); if (!created) { return reply .code(400) .send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `Webview limit reached (max ${MAX_WEBVIEWS})`)); } ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id }); return { success: true, data: created }; }); app.patch<{ Params: { id: string } }>('/api/webviews/:id', async (req, reply) => { const input = parseBody(WebviewUpdateSchema, req.body); const user = getAuthUser(req); const { id } = req.params; const updated = await withWebviews(async (list) => { const index = list.findIndex((w) => w.id === id); if (index === -1) return 'not-found' as const; if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const; const next: Webview = { ...list[index], ...input }; list[index] = next; await writeWebviews(configDir(), list); return next; }); if (updated === 'not-found') { return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found')); } if (updated === 'forbidden') { return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview')); } // Any edit invalidates the outstanding capability. Otherwise a token minted // against the OLD url keeps proxying to it after the user repointed the tab. webviewCapabilities.revokeWebview(id); ctx.broadcast(SseEvent.WebviewChanged, { action: 'updated', id }); return { success: true, data: updated }; }); app.delete<{ Params: { id: string } }>('/api/webviews/:id', async (req, reply) => { const user = getAuthUser(req); const { id } = req.params; const result = await withWebviews(async (list) => { const index = list.findIndex((w) => w.id === id); if (index === -1) return 'not-found' as const; if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const; list.splice(index, 1); await writeWebviews(configDir(), list); return 'deleted' as const; }); if (result === 'not-found') { return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found')); } if (result === 'forbidden') { return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview')); } webviewCapabilities.revokeWebview(id); socketCounts.delete(id); ctx.broadcast(SseEvent.WebviewChanged, { action: 'deleted', id }); return { success: true, data: { id } }; }); /** * Reachability + framing probe for the editor's "Test" button. * * Runs from the SERVER, which is the network position the proxy will use, so a * green result here means the proxy will actually work. Never throws upstream * failures at the caller: an unreachable dashboard is a normal answer, not a 500. */ app.post('/api/webviews/probe', async (req) => { const { url } = parseBody(WebviewProbeSchema, req.body); return { success: true, data: await probeUrl(url) }; }); /** * Mint the capability the iframe will load. Separate from GET /api/webviews so a * capability exists only for dashboards actually opened, and so the TTL clock * starts on open rather than on page load. */ app.post<{ Params: { id: string } }>('/api/webviews/:id/open', async (req, reply) => { const user = getAuthUser(req); const { id } = req.params; const webview = await withWebviews(async (list) => { const index = list.findIndex((w) => w.id === id); if (index === -1) return 'not-found' as const; if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const; list[index] = { ...list[index], lastOpenedAt: Date.now() }; await writeWebviews(configDir(), list); return list[index]; }); if (webview === 'not-found') { return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found')); } if (webview === 'forbidden') { return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview')); } // Direct mode has no capability to mint: the iframe loads the real URL. if (webview.embedMode === 'direct') { const data: WebviewOpenData = { webview }; return { success: true, data }; } const capability = webviewCapabilities.mint(webview.id, webview.owner); const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability) }; return { success: true, data }; }); } async function probeUrl(url: string): Promise { const target = parseWebviewUrl(url); if (!target) { return { reachable: false, framable: false, recommendedMode: 'proxy', reason: 'Invalid URL', }; } try { const response = await fetch(target.href, { method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(WEBVIEW_PROBE_TIMEOUT_MS), }); // The body is irrelevant to the probe; release the socket rather than leak it. await response.body?.cancel().catch(() => undefined); const xFrameOptions = response.headers.get('x-frame-options') ?? undefined; const csp = response.headers.get('content-security-policy') ?? undefined; const frameAncestors = extractFrameAncestors(csp); const framable = isFramableCrossOrigin(xFrameOptions, csp); const isHttp = target.protocol === 'http:'; // Direct embedding is only viable for an HTTPS target that permits framing: // an HTTPS Codeman page cannot embed http:// at all (mixed content). const recommendedMode = !isHttp && framable ? 'direct' : 'proxy'; const reason = isHttp ? 'Plain HTTP: an HTTPS Codeman page cannot embed it directly, so it is proxied.' : framable ? 'Reachable and allows framing: can be embedded directly.' : 'Reachable but refuses framing, so it is proxied.'; return { reachable: true, status: response.status, xFrameOptions, frameAncestors, framable, recommendedMode, reason, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { reachable: false, framable: false, recommendedMode: 'proxy', reason: `Server could not reach it: ${message}`, }; } } // ───────────────────────────── Proxy ───────────────────────────── function registerProxyRoutes(app: FastifyInstance): void { app.register(async (scope) => { // Encapsulated to this plugin only. The proxy must relay request bodies // BYTE-FOR-BYTE, so every parser is replaced with a pass-through that hands // back the raw stream. Doing this on the root instance would break JSON // routes and un-fix the text/plain CSRF hardening. scope.removeAllContentTypeParsers(); scope.addContentTypeParser('*', (_req, payload, done) => done(null, payload)); // A single GET route serving both roles: `handler` for normal requests, // `wsHandler` for upgrades. Registering them as two routes on one URL would // collide. scope.route<{ Params: ProxyParams }>({ method: 'GET', url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`, handler: proxyHttp, wsHandler: proxyWebSocket, }); // HEAD is deliberately absent: Fastify's `exposeHeadRoutes` already derives a // HEAD route from the GET above, and declaring it again is a startup error. scope.route<{ Params: ProxyParams }>({ method: ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`, handler: proxyHttp, }); // `/webview/` with no trailing slash: redirect rather than serve, so the // browser's notion of the base path ends in `/` and relative URLs in the // dashboard's HTML resolve inside the prefix instead of one level above it. scope.get<{ Params: { cap: string } }>(`${WEBVIEW_PROXY_PREFIX}/:cap`, (req, reply) => { return reply.redirect(proxyPrefixFor(req.params.cap), 302); }); }); } /** Resolve a capability to its live webview record, or null. */ async function lookupCapability(capability: string): Promise { const record = webviewCapabilities.resolve(capability); if (!record) return null; const list = await readWebviews(configDir()); const webview = list.find((w) => w.id === record.webviewId); if (!webview) return null; // The capability is bound to the identity that minted it; an ownership change // on the record must not leave a stale token working. if (webview.owner !== record.owner) return null; return webview; } /** * ⚠ Every exit path RETURNS `reply.send(...)`. * * This handler is `async`, and Fastify resolves an async handler's promise as the * response. `reply.send(stream)` followed by a bare `return` resolves to * `undefined` before the stream has been consumed, and Fastify then answers with * an EMPTY body: HTML (a synchronously-set string payload) survives it, every * streamed asset comes back zero-length. Returning the reply is what tells Fastify * the response is already owned by this handler. */ function proxyHttp(req: FastifyRequest<{ Params: ProxyParams }>, reply: FastifyReply): Promise { return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? ''); } /** * Proxy one request to the dashboard behind `cap`, serving `wildcard` as the * upstream path. Split out from the route handler so the 404 fallback (which has * no route params) can reuse it. */ async function proxyRequest( req: FastifyRequest, reply: FastifyReply, cap: string, wildcard: string ): Promise { const webview = await lookupCapability(cap); if (!webview) { return reply.code(403).type('text/plain').send('Forbidden: unknown or expired webview capability'); } // CORS is required even though the URL is on this host: a sandboxed dashboard is // opaque-origin, so its fetch/XHR are cross-origin requests. See // buildProxyCorsHeaders. const cors = buildProxyCorsHeaders( typeof req.headers.origin === 'string' ? req.headers.origin : undefined, typeof req.headers['access-control-request-headers'] === 'string' ? req.headers['access-control-request-headers'] : undefined ); // Answer the preflight here rather than relaying it: the dashboard has no reason // to know it is being framed, and most would reject an unexpected `Origin: null`. if (req.method === 'OPTIONS' && req.headers['access-control-request-method']) { for (const [key, value] of Object.entries(cors)) reply.header(key, value); return reply.code(204).send(); } const queryStart = req.url.indexOf('?'); const search = queryStart === -1 ? '' : req.url.slice(queryStart); const upstream = resolveUpstreamUrl(webview.url, wildcard, search); if (!upstream) { return reply.code(400).type('text/plain').send('Bad Request: path escapes the dashboard origin'); } const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; const headers = buildUpstreamRequestHeaders(req.headers, upstream, { forwardCookies: webview.trusted, sessionCookieName: AUTH_COOKIE_NAME, refererPath: typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap) : undefined, }); // #237: the timeout bounds TIME-TO-HEADERS only. A plain AbortSignal.timeout on // the fetch bounded the entire exchange, so a legitimately slow endpoint (AI // inference behind the dashboard) and an actively streaming response both died at // 30s as an unlogged generic 502. The timer is cleared the moment headers arrive; // what reclaims an abandoned upstream afterwards is the client hangup below. const startedAt = Date.now(); const abort = new AbortController(); let headerTimedOut = false; let clientGone = false; const headerTimer = setTimeout(() => { headerTimedOut = true; abort.abort(); }, WEBVIEW_UPSTREAM_TIMEOUT_MS); // A browser that navigates away mid-request (or mid-stream) must abort the // upstream fetch, or slow endpoints accumulate as orphaned upstream sockets. // Guarded by writableFinished, same as abortOnClientHangUp in session-routes: // `close` also fires after a completed response, which must not abort anything. reply.raw.on('close', () => { if (!reply.raw.writableFinished) { clientGone = true; abort.abort(); } }); // Sanitized request identity for logs: method + origin + path, never the query // string (it can carry the dashboard's tokens). const logTarget = `${req.method} ${upstream.origin}${upstream.pathname}`; let response: Response; try { response = await fetch(upstream.href, { method: req.method, headers, body: hasBody ? (req.body as Readable) : undefined, // Required by undici whenever the body is a stream. ...(hasBody ? { duplex: 'half' } : {}), // Redirects are rewritten into the proxy prefix instead of followed, so the // browser's URL stays inside the frame and relative assets keep resolving. redirect: 'manual', signal: abort.signal, } as RequestInit); } catch (err) { const elapsed = Date.now() - startedAt; if (clientGone) { // Nobody is listening; the abort was ours and intentional. Not an upstream // failure, so no warn (it would read as the dashboard being broken). return reply; } if (headerTimedOut) { console.warn( `[Webview] upstream sent no response headers within ${WEBVIEW_UPSTREAM_TIMEOUT_MS}ms: ` + `${logTarget} (webview "${webview.name}")` ); return reply .code(502) .type('text/plain') .send( `Dashboard unreachable: upstream sent no response headers within ${WEBVIEW_UPSTREAM_TIMEOUT_MS}ms ` + `(CODEMAN_WEBVIEW_TIMEOUT_MS raises this limit)` ); } const message = err instanceof Error ? err.message : String(err); console.warn( `[Webview] upstream fetch failed after ${elapsed}ms: ${logTarget} (webview "${webview.name}"): ${message}` ); return reply.code(502).type('text/plain').send(`Dashboard unreachable: ${message}`); } finally { // Headers arrived (or the fetch failed): from here on the timeout must never // fire, a streaming body is allowed to take as long as it takes. clearTimeout(headerTimer); } const secureContext = req.protocol === 'https'; const { headers: outHeaders, setCookie, csp, } = buildDownstreamResponseHeaders( response.headers as unknown as Iterable<[string, string]>, response.headers.getSetCookie(), cap, upstream, secureContext ); reply.code(response.status); for (const [key, value] of Object.entries(outHeaders)) reply.header(key, value); // After the upstream headers, so ours win: an upstream ACAO would name the // dashboard's own origin, not the opaque origin this frame actually has. for (const [key, value] of Object.entries(cors)) reply.header(key, value); for (const cookie of setCookie) reply.header('set-cookie', cookie); // registerSecurityHeaders already stamped Codeman's own `default-src 'self'` // policy on this reply during onRequest. Left in place it breaks essentially // every dashboard (inline scripts, CDN assets), so it is replaced by the // upstream's own policy, or removed when the upstream had none. if (csp) reply.header('content-security-policy', csp); else reply.removeHeader('content-security-policy'); if (!response.body || req.method === 'HEAD') { return reply.send(); } const contentType = response.headers.get('content-type') ?? undefined; const declaredLength = Number(response.headers.get('content-length') ?? '0'); const rewritable = isHtmlContentType(contentType) && declaredLength <= MAX_WEBVIEW_HTML_REWRITE_BYTES; if (rewritable) { // Buffer only HTML, only under the cap: `` injection needs the whole // document, and buffering an unbounded upstream body is a memory hazard. const html = await response.text(); return reply.send(html.length <= MAX_WEBVIEW_HTML_REWRITE_BYTES ? rewriteHtml(html, cap) : html); } return reply.send(Readable.fromWeb(response.body as Parameters[0])); } /** * Last-resort handler for a dashboard asset requested with a ROOT-ABSOLUTE URL. * * `` fixes relative URLs and the HTML rewrite fixes `src`/`href`/`action` * attributes, but neither can reach a URL built at runtime: `fetch('/api/data')`, * `import('/chunk.js')`, `url(/img.png)` inside a stylesheet. Those arrive at * Codeman's root and 404. * * The `Referer` identifies which dashboard asked, so the request can be routed to * the right upstream. Wiring it into the 404 handler rather than a catch-all route * is what keeps it contained: every real Codeman route matches first, and this only * ever sees requests that were going to fail anyway. * * @returns true when the request was handled (caller must not also reply). */ export async function tryWebviewRefererFallback(req: FastifyRequest, reply: FastifyReply): Promise { // Safe methods only. A write arriving here has already lost its raw body to the // root instance's JSON parser, so it could not be relayed faithfully anyway. if (req.method !== 'GET' && req.method !== 'HEAD') return false; const capability = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined); if (!capability) return false; if (!webviewCapabilities.resolve(capability)) return false; const path = req.url.split('?')[0].replace(/^\//, ''); await proxyRequest(req, reply, capability, path); return true; } /** Turn a proxy-side Referer back into the upstream path it corresponds to. */ function stripProxyPrefix(referer: string, capability: string): string | undefined { try { const url = new URL(referer); const prefix = proxyPrefixFor(capability); if (!url.pathname.startsWith(prefix)) return undefined; return `/${url.pathname.slice(prefix.length)}${url.search}`; } catch { return undefined; } } // ─────────────────────────── WebSocket ─────────────────────────── /** * Relay a WebSocket through to the dashboard. * * Live dashboards (Grafana, Home Assistant, Uptime Kuma) push over WebSocket, so * without this leg they load but their realtime panels stay permanently empty. * * The upgrade is guarded on the capability, NOT on `Origin`: a sandboxed iframe is * opaque-origin, so its upgrade arrives with `Origin: null`. The host allowlist * still applies (it runs in the global onRequest hook), so DNS-rebinding * protection is unaffected. */ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyParams }>): void { const { cap } = req.params; void (async () => { const webview = await lookupCapability(cap); if (!webview) { socket.close(4003, 'Forbidden'); return; } const live = socketCounts.get(webview.id) ?? 0; if (live >= MAX_WEBVIEW_SOCKETS) { socket.close(4008, 'Too many connections'); return; } const wildcard = req.params['*'] ?? ''; const queryStart = req.url.indexOf('?'); const search = queryStart === -1 ? '' : req.url.slice(queryStart); const upstream = resolveUpstreamUrl(webview.url, wildcard, search); if (!upstream) { socket.close(4003, 'Forbidden'); return; } socketCounts.set(webview.id, live + 1); let released = false; const release = () => { if (released) return; released = true; const count = socketCounts.get(webview.id) ?? 1; if (count <= 1) socketCounts.delete(webview.id); else socketCounts.set(webview.id, count - 1); }; const protocols = req.headers['sec-websocket-protocol']; const upstreamSocket = new WsClient( upstreamWebSocketUrl(upstream), protocols ? String(protocols).split(/,\s*/) : [], { headers: { origin: upstream.origin, ...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}), }, handshakeTimeout: WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS, } ); // Buffer anything the browser sends before the upstream handshake completes, // rather than dropping it: a client that sends a subscribe frame immediately // would otherwise sit connected and silent forever. const pending: Array = []; let upstreamOpen = false; upstreamSocket.on('open', () => { upstreamOpen = true; for (const message of pending) upstreamSocket.send(message); pending.length = 0; }); socket.on('message', (data: Buffer, isBinary: boolean) => { const payload = isBinary ? data : data.toString(); if (upstreamOpen) upstreamSocket.send(payload); else if (pending.length < 64) pending.push(payload); }); upstreamSocket.on('message', (data: Buffer, isBinary: boolean) => { if (socket.readyState === socket.OPEN) socket.send(isBinary ? data : data.toString()); }); // Paired close in both directions, so neither side is left half-open. const closeBoth = (code?: number, reason?: string) => { release(); // Codes outside 3000-4999 (and 1000/1001) are not valid to send onward. const safeCode = code && code >= 3000 && code <= 4999 ? code : 1000; if (socket.readyState === socket.OPEN) socket.close(safeCode, reason); if (upstreamSocket.readyState === WsClient.OPEN || upstreamSocket.readyState === WsClient.CONNECTING) { upstreamSocket.close(safeCode, reason); } }; socket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString())); upstreamSocket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString())); socket.on('error', () => closeBoth()); upstreamSocket.on('error', () => { release(); if (socket.readyState === socket.OPEN) socket.close(1011, 'Upstream error'); }); })(); }