/** * @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 { ClientOptions as WsClientOptions, WebSocket } from 'ws'; import type { Response as UndiciResponse } from 'undici'; 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 { egressBlockedReason, webviewEgressLookup, webviewFetch, type EgressLookup } from '../webview-egress.js'; import { blockedWebviewHostReason } from '../webview-egress-policy.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, TabLayoutPort } from '../ports/index.js'; import { ownerLayoutKey } from '../../tab-layout-persistence.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 & TabLayoutPort, basePath = ''): void { registerCrudRoutes(app, ctx, basePath); registerProxyRoutes(app, basePath); } // ───────────────────────────── CRUD ───────────────────────────── function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort, basePath: string): 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, managed: input.managed, 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})`)); } try { await ctx.tabLayouts.webviewCreated(ownerLayoutKey(created.owner)); } catch (error) { // The saved webview and its layout ref are one logical creation. If the // layout rejects the new ref (for example at MAX_TAB_REFS), roll back the // already-written JSON record and publish neither creation event. await withWebviews(async (list) => { const index = list.findIndex((webview) => webview.id === created.id); if (index >= 0) { list.splice(index, 1); await writeWebviews(configDir(), list); } }); throw error; } ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id, owner: ownerLayoutKey(created.owner), }); 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, owner: ownerLayoutKey(updated.owner), }); 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; const removed = list[index]; list.splice(index, 1); await writeWebviews(configDir(), list); try { await ctx.tabLayouts.webviewDeleted(ownerLayoutKey(removed.owner), id); } catch (error) { // Still inside withWebviews' mutex: restore the exact record at its // original position without overwriting any concurrent mutation. list.splice(index, 0, removed); await writeWebviews(configDir(), list); throw error; } return { status: 'deleted' as const, owner: removed.owner }; }); 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, owner: ownerLayoutKey(result.owner), }); 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, basePath) }; 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 webviewFetch(target, { 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 blocked = egressBlockedReason(err); if (blocked) { // Refused by policy, not unreachable: say so, or the user reads it as a // network problem and starts debugging their firewall. return { reachable: false, framable: false, recommendedMode: 'proxy', reason: blocked }; } 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, basePath: string): 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. (The WS leg produces no browser-facing URLs, so it needs no base.) scope.route<{ Params: ProxyParams }>({ method: 'GET', url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`, handler: (req, reply) => proxyHttp(req, reply, basePath), 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: (req, reply) => proxyHttp(req, reply, basePath), }); // `/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, basePath), 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, basePath: string ): Promise { return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? '', basePath); } /** * 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, basePath = '' ): 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, basePath) : 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: UndiciResponse; try { response = await webviewFetch(upstream, { method: req.method, headers, body: hasBody ? (req.body as Readable) : undefined, // Required by undici whenever the body is a stream. ...(hasBody ? { duplex: 'half' as const } : {}), // 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, }); } 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; } const blocked = egressBlockedReason(err); if (blocked) { // Policy refusal, distinct from "unreachable": a record saved before the // egress rule existed, or a name that now resolves into a blocked range. console.warn(`[Webview] refused by egress policy: ${logTarget} (webview "${webview.name}"): ${blocked}`); return reply.code(403).type('text/plain').send(`Forbidden: ${blocked}`); } 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, basePath ); 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, basePath) : 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, basePath = '' ): 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, basePath ); if (!capability) return false; if (!webviewCapabilities.resolve(capability)) return false; // req.url is already base-stripped by the server's rewriteUrl, so this is the // internal path the upstream resolver expects. const path = req.url.split('?')[0].replace(/^\//, ''); await proxyRequest(req, reply, capability, path, basePath); return true; } /** Turn a proxy-side Referer back into the upstream path it corresponds to. */ function stripProxyPrefix(referer: string, capability: string, basePath = ''): string | undefined { try { const url = new URL(referer); const prefix = proxyPrefixFor(capability, basePath); 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; } // An IP literal never reaches the lookup hook (net.connect skips DNS for it), // so the literal form is judged here and the resolved form in the lookup. if (blockedWebviewHostReason(upstream.hostname)) { 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']; // `lookup` is absent from ws's ClientOptions typings but flows through // http.request to net.connect untouched, which is where the resolved // address is judged (see webview-egress.ts). const upstreamOptions: WsClientOptions & { lookup: EgressLookup } = { headers: { origin: upstream.origin, ...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}), }, handshakeTimeout: WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS, lookup: webviewEgressLookup, }; const upstreamSocket = new WsClient( upstreamWebSocketUrl(upstream), protocols ? String(protocols).split(/,\s*/) : [], upstreamOptions ); // 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', (err: Error) => { release(); if (socket.readyState !== socket.OPEN) return; // A name that resolved into a blocked range fails inside the connect, so it // surfaces here rather than at the sync check above; report it as the same // policy refusal, not as the dashboard being broken. if (egressBlockedReason(err)) socket.close(4003, 'Forbidden'); else socket.close(1011, 'Upstream error'); }); })(); }