import { ServerResponse, type IncomingMessage, type RequestListener, type Server } from "node:http"; import { request as httpsRequest } from "node:https"; import type { Socket } from "node:net"; import type { Duplex } from "node:stream"; import { validateHeaderName, validateHeaderValue } from "node:http"; import type { Request, Response } from "express"; export const pluginApiUpgrade = Symbol("pluginApiUpgrade"); type UpgradeRequest = IncomingMessage & { paperclipWebSocketHandled?: boolean; [pluginApiUpgrade]?: { socket: Socket; head: Buffer }; }; /** Reuse the full HTTP authentication/company pipeline for plugin upgrades. */ export function setupPluginApiWebSocketServer(server: Server, handler: RequestListener) { server.on("upgrade", (request: UpgradeRequest, socket: Socket, head: Buffer) => { if (request.paperclipWebSocketHandled || !/^\/api\/plugins\/[^/]+\/api\//.test(request.url ?? "")) return; request.paperclipWebSocketHandled = true; socket.on("error", () => socket.destroy()); if (request.method !== "GET" || request.headers.upgrade?.toLowerCase() !== "websocket" || request.headers["content-length"] || request.headers["transfer-encoding"]) { socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); return; } request[pluginApiUpgrade] = { socket, head }; const response = new ServerResponse(request); response.assignSocket(socket); response.setHeader("Connection", "close"); response.once("finish", () => socket.end()); handler(request, response); }); } /** Only worker-selected WSS targets and credentials reach the upstream. */ export function proxyPluginApiWebSocket( req: Request, res: Response, target: { url: string; headers?: Record }, available: () => boolean, ) { const upgrade = (req as UpgradeRequest)[pluginApiUpgrade]; if (!upgrade || upgrade.socket.destroyed) return; const { socket, head } = upgrade; let url: URL; try { url = new URL(target.url); if (url.protocol !== "wss:" || url.username || url.password || url.hash) throw new Error(); for (const [name, value] of Object.entries(target.headers ?? {})) { validateHeaderName(name); validateHeaderValue(name, value); } // A plugin may supply upstream authorization, never browser cookies/origin. if (Object.keys(target.headers ?? {}).some(key => /^(cookie|origin|host|connection|upgrade|sec-websocket-|x-forwarded-)/i.test(key))) throw new Error(); } catch { res.status(502).json({ error: "Plugin connection unavailable" }); return; } url.protocol = "https:"; const headers: Record = { ...target.headers, Connection: "Upgrade", Upgrade: "websocket" }; for (const name of ["sec-websocket-key", "sec-websocket-version", "sec-websocket-protocol", "sec-websocket-extensions"]) { if (req.headers[name]) headers[name] = req.headers[name]!; } let connected = false; let upstream: Duplex | undefined; const fail = () => { if (!connected && !socket.destroyed && !res.headersSent) res.status(502).json({ error: "Plugin connection unavailable" }); else socket.destroy(); }; let connection: ReturnType; try { connection = httpsRequest(url, { method: "GET", headers, timeout: 15_000 }); } catch { fail(); return; } const cleanup = () => { clearInterval(watch); clearTimeout(lifetime); connection.destroy(); upstream?.destroy(); }; // Periodic reconnect reauthorizes the browser through HTTP; it also bounds // how long a removed membership or changed plugin binding can retain access. const lifetime = setTimeout(() => socket.destroy(), 5 * 60_000); const watch = setInterval(() => { if (!available()) socket.destroy(); }, 5_000); lifetime.unref(); watch.unref(); socket.once("close", cleanup); connection.on("timeout", () => { connection.destroy(); fail(); }); connection.on("error", fail); connection.on("response", response => { response.destroy(); fail(); }); connection.on("upgrade", (response, remote, remoteHead) => { upstream = remote; if (socket.destroyed || !available() || response.statusCode !== 101) { remote.destroy(); fail(); return; } connected = true; connection.setTimeout(0); socket.setTimeout(0); res.detachSocket(socket); // Express request accounting must observe the lifetime of the upgraded stream. socket.once("close", () => res.emit("close")); let handshake = "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n"; for (const name of ["sec-websocket-accept", "sec-websocket-protocol", "sec-websocket-extensions"]) { const value = response.headers[name]; if (typeof value === "string") handshake += `${name}: ${value}\r\n`; } socket.write(handshake + "\r\n"); if (remoteHead.length) socket.write(remoteHead); if (head.length) remote.write(head); remote.on("error", () => socket.destroy()); remote.once("close", () => socket.destroy()); socket.pipe(remote).pipe(socket); }); connection.end(); }