// Isolated host for a Capsule's opt-in rich Display element. // // The rich element is additive Display: arbitrary rendering (canvas, WebGL) // with zero authority. It runs in an iframe that is sandboxed with // allow-scripts only and served from this dedicated listener, a distinct // origin from the app, so nothing of Clay's DOM, cookies, or storage is // reachable. Its one outward edge is postMessage to its embedder, and the // only thing it can ask for is the same validated act a human click requests. // // The frame document carries its own CSP, strictly tighter than the app // policy: default-src 'none' with a per-response script nonce. There is no // connect-src at all, so the frame cannot fetch, beacon, or open sockets; a // rich element ships self-contained. The nonce authorizes exactly two // scripts: the inline bridge below and the Capsule's own display.js. // // This origin serves no cookies and knows no sessions. A frame URL is gated // by a short-lived token issued over the user's authenticated WebSocket, and // each token admits one shell fetch and one display.js fetch. Whatever state // the host later pushes into the frame is the entire exfiltration surface, // chosen per element, exactly like a snapshot projection is chosen per caller. var crypto = require("crypto"); var fs = require("fs"); var http = require("http"); var https = require("https"); var path = require("path"); var toolsRegistry = require("./tools-registry"); var TOKEN_TTL_MS = 60000; var DISPLAY_FILE = "display.js"; var MAX_DISPLAY_BYTES = 512 * 1024; function createCapsuleFrameServer(opts) { opts = opts || {}; var tlsOptions = opts.tlsOptions || null; var tokens = Object.create(null); var server = null; var listening = null; function pruneTokens(now) { var keys = Object.keys(tokens); for (var i = 0; i < keys.length; i++) { if (tokens[keys[i]].expiresAt <= now) delete tokens[keys[i]]; } } function displayPathFor(ctx, toolId) { toolsRegistry.validateToolId(toolId); return path.join(toolsRegistry.resolveToolsRoot(ctx), toolId, DISPLAY_FILE); } function hasRichDisplay(ctx, toolId) { try { return fs.existsSync(displayPathFor(ctx, toolId)); } catch (error) { return false; } } function ensureListening() { if (listening) return listening; server = tlsOptions ? https.createServer(tlsOptions, handleRequest) : http.createServer(handleRequest); listening = new Promise(function (resolve, reject) { server.once("error", reject); server.listen(opts.port || 0, function () { resolve(server.address().port); }); }); return listening; } // Issues the one-time frame URL for a Capsule that ships a rich element. // Callable only from the authenticated WebSocket side, which is what binds // the anonymous frame origin to a specific user's tools root. function issueFrameUrl(ctx, toolId) { if (!hasRichDisplay(ctx, toolId)) { return Promise.reject(new Error("This Capsule ships no rich Display element.")); } return ensureListening().then(function (port) { pruneTokens(Date.now()); var token = crypto.randomBytes(24).toString("hex"); tokens[token] = { ctx: { userId: ctx.userId, multiUser: ctx.multiUser, linuxUser: ctx.linuxUser }, toolId: toolId, expiresAt: Date.now() + TOKEN_TTL_MS, shell: true, display: true, }; return { port: port, path: "/capsule/?t=" + token, secure: !!tlsOptions }; }); } function consumeToken(token, use) { if (typeof token !== "string" || !token) return null; var entry = tokens[token]; if (!entry) return null; if (entry.expiresAt <= Date.now()) { delete tokens[token]; return null; } if (!entry[use]) return null; entry[use] = false; if (!entry.shell && !entry.display) delete tokens[token]; return entry; } function deny(res, code) { res.writeHead(code, { "Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff" }); res.end(code === 404 ? "Not found" : "Forbidden"); } // The in-frame half of the bridge. Its only capabilities are receiving // state/event pushes from the embedder and asking the embedder for an act. // The embedder routes that request into the same pipeline a floor button // uses; nothing here can mutate anything directly. function bridgeSource() { return [ "(function () {", " \"use strict\";", " var api = {", " onState: null,", " onEvent: null,", " act: function (actionId, args) {", " window.parent.postMessage({ clayCapsuleFrame: 1, type: \"act\", actionId: String(actionId), args: args || {} }, \"*\");", " },", " };", " window.ClayCapsule = api;", " window.addEventListener(\"message\", function (event) {", " if (event.source !== window.parent) return;", " var data = event.data;", " if (!data || data.clayCapsuleFrame !== 1) return;", " if (data.type === \"state\" && typeof api.onState === \"function\") api.onState(data.state || {});", " else if (data.type === \"event\" && typeof api.onEvent === \"function\") api.onEvent(data.event || null);", " });", " // The host owns the iframe box, so the frame reports its natural", " // content height and the host resizes the box to fit. Without this", " // the box stays fixed and a widening layout clips the element.", " var reportedHeight = 0;", " var reportQueued = false;", " function reportSize() {", " reportQueued = false;", " var root = document.documentElement;", " var body = document.body;", " var height = Math.ceil(Math.max(root ? root.scrollHeight : 0, body ? body.scrollHeight : 0));", " if (!height || height === reportedHeight) return;", " reportedHeight = height;", " window.parent.postMessage({ clayCapsuleFrame: 1, type: \"size\", height: height }, \"*\");", " }", " function queueReport() {", " if (reportQueued) return;", " reportQueued = true;", " if (typeof requestAnimationFrame === \"function\") requestAnimationFrame(reportSize);", " else setTimeout(reportSize, 50);", " }", " if (typeof ResizeObserver === \"function\") {", " new ResizeObserver(queueReport).observe(document.documentElement);", " if (document.body) new ResizeObserver(queueReport).observe(document.body);", " }", " window.addEventListener(\"resize\", queueReport);", " window.addEventListener(\"load\", function () {", " window.parent.postMessage({ clayCapsuleFrame: 1, type: \"ready\" }, \"*\");", " queueReport();", " });", "})();", ].join("\n"); } function shellHtml(nonce, token) { return [ "", "", "
", "", "