UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

245 lines (244 loc) 13.2 kB
import { a as asOptionalRecord } from "./record-coerce-DItp3I4t.js"; //#region src/agents/sandbox-host.ts const SANDBOX_HOST_PATH = "/mcp-app-sandbox"; const SANDBOX_HOST_PORT_OFFSET = 1; const SANDBOX_HOST_CSP_QUERY = "csp"; const SANDBOX_HOST_CSP_MAX_JSON_BYTES = 5120; const SANDBOX_HOST_CSP_MAX_HEADER_BYTES = 6144; const SANDBOX_HOST_CSP_MAX_ENCODED_BYTES = Math.ceil(SANDBOX_HOST_CSP_MAX_JSON_BYTES / 3) * 4 + 4; function buildSandboxDocumentGuardHtml(blockDescendantFrames) { if (!blockDescendantFrames) return ""; return `<script>(()=>{ const blocked=["iframe","frame","object","embed","portal","fencedframe","webview","browser"]; const apply=Reflect.apply; const defineProperty=Object.defineProperty; const getOwnPropertyDescriptor=Object.getOwnPropertyDescriptor; const toString=String; const toLowerCase=String.prototype.toLowerCase; const lastIndexOf=String.prototype.lastIndexOf; const slice=String.prototype.slice; const includes=Array.prototype.includes; const regexpTest=RegExp.prototype.test; const descendantMarkup=/<\\s*(?:iframe|frame|object|embed|portal|fencedframe|webview|browser)\\b/iu; const reject=()=>{throw new Error("sandbox descendant browsing contexts are disabled");}; const assertName=name=>{const value=apply(toLowerCase,toString(name),[]);const separator=apply(lastIndexOf,value,[":"]);const localName=separator<0?value:apply(slice,value,[separator+1]);if(apply(includes,blocked,[localName]))reject();}; const assertMarkup=value=>{if(apply(regexpTest,descendantMarkup,[toString(value)]))reject();}; const lock=(target,name,value)=>{try{defineProperty(target,name,{value,writable:false,configurable:false});}catch{}}; for(const name of ["RTCPeerConnection","webkitRTCPeerConnection","RTCIceGatherer","RTCIceTransport","RTCDtlsTransport","RTCSctpTransport","RTCDataChannel"])lock(globalThis,name,undefined); const createElement=Document.prototype.createElement; lock(Document.prototype,"createElement",function(name,...args){assertName(name);return Reflect.apply(createElement,this,[name,...args]);}); const createElementNS=Document.prototype.createElementNS; lock(Document.prototype,"createElementNS",function(namespace,name,...args){assertName(name);return Reflect.apply(createElementNS,this,[namespace,name,...args]);}); const wrapSetter=(target,name)=>{const descriptor=getOwnPropertyDescriptor(target,name);if(!descriptor?.set)return;try{defineProperty(target,name,{get:descriptor.get,set(value){assertMarkup(value);return apply(descriptor.set,this,[value]);},enumerable:descriptor.enumerable,configurable:false});}catch{}}; wrapSetter(Element.prototype,"innerHTML");wrapSetter(Element.prototype,"outerHTML"); if(globalThis.ShadowRoot)wrapSetter(ShadowRoot.prototype,"innerHTML"); const wrapMethod=(target,name,indexes)=>{const original=target?.[name];if(typeof original!=="function")return;lock(target,name,function(...args){if(indexes){for(let index=0;index<indexes.length;index+=1)assertMarkup(args[indexes[index]]);}else{for(let index=0;index<args.length;index+=1)assertMarkup(args[index]);}return apply(original,this,args);});}; wrapMethod(Element.prototype,"insertAdjacentHTML",[1]);wrapMethod(Document.prototype,"write");wrapMethod(Document.prototype,"writeln");wrapMethod(Range.prototype,"createContextualFragment",[0]);wrapMethod(DOMParser.prototype,"parseFromString",[0]); wrapMethod(Element.prototype,"setHTMLUnsafe",[0]);wrapMethod(Element.prototype,"setHTML",[0]); if(globalThis.ShadowRoot){wrapMethod(ShadowRoot.prototype,"setHTMLUnsafe",[0]);wrapMethod(ShadowRoot.prototype,"setHTML",[0]);} lock(globalThis,"open",undefined); })();<\/script>`; } const RESOLVE_LEADING_DOCTYPE_END_SOURCE = `(html) => { let index = html.charCodeAt(0) === 0xfeff ? 1 : 0; const whitespace = new Set([9, 10, 12, 13, 32]); while (true) { while (index < html.length && whitespace.has(html.charCodeAt(index))) index += 1; if (html.slice(index, index + 4) !== "<!--") break; const commentEnd = html.indexOf("-->", index + 4); if (commentEnd < 0) return 0; index = commentEnd + 3; } if (html.slice(index, index + 9).toLowerCase() !== "<!doctype") return 0; let quote = ""; for (let cursor = index + 9; cursor < html.length; cursor += 1) { const char = html[cursor]; if (quote) { if (char === quote) quote = ""; continue; } if (char === '"' || char === "'") { quote = char; continue; } if (char === ">") return cursor + 1; } return 0; }`; function normalizeDomains(value, options) { if (!Array.isArray(value)) return; const allowedProtocols = options?.allowWebSocket ? /* @__PURE__ */ new Set([ "http:", "https:", "ws:", "wss:" ]) : /* @__PURE__ */ new Set(["http:", "https:"]); const entries = value.filter((entry) => { if (typeof entry !== "string" || entry.length === 0 || entry.length > 2048 || entry !== entry.trim()) return false; for (let index = 0; index < entry.length; index += 1) { const code = entry.charCodeAt(index); if (code <= 31 || code === 127) return false; } let parsed; try { parsed = new URL(entry); } catch { return false; } if (!allowedProtocols.has(parsed.protocol) || parsed.username !== "" || parsed.password !== "" || parsed.pathname !== "/" || parsed.search !== "" || parsed.hash !== "") return false; return /^\[[0-9A-Fa-f:.]+\]$/u.test(parsed.hostname) || /^(?:\*\.)?[A-Za-z0-9.-]+$/u.test(parsed.hostname); }).map((entry) => new URL(entry).origin); return entries.length > 0 ? entries : void 0; } function normalizeSandboxHostCsp(value) { const record = asOptionalRecord(value); if (!record) return; const csp = { connectDomains: normalizeDomains(record.connectDomains, { allowWebSocket: true }), resourceDomains: normalizeDomains(record.resourceDomains), frameDomains: normalizeDomains(record.frameDomains), baseUriDomains: normalizeDomains(record.baseUriDomains), blockDescendantFrames: record.blockDescendantFrames === true ? true : void 0 }; if (!Object.values(csp).some(Boolean)) return; const jsonBytes = Buffer.byteLength(JSON.stringify(csp), "utf8"); const headerBytes = Buffer.byteLength(buildSandboxHostContentSecurityPolicy(csp), "utf8"); if (jsonBytes > SANDBOX_HOST_CSP_MAX_JSON_BYTES || headerBytes > SANDBOX_HOST_CSP_MAX_HEADER_BYTES) throw new Error("MCP App CSP metadata exceeds safe HTTP limits"); return csp; } function encodeCsp(csp) { const normalized = normalizeSandboxHostCsp(csp); if (!normalized) return; return Buffer.from(JSON.stringify(normalized), "utf8").toString("base64url"); } function buildSandboxHostPath(csp) { const encoded = encodeCsp(csp); return encoded ? `${SANDBOX_HOST_PATH}?${SANDBOX_HOST_CSP_QUERY}=${encoded}` : SANDBOX_HOST_PATH; } function resolveSandboxHostPort(gatewayPort, configuredPort) { const sandboxPort = configuredPort ?? gatewayPort + SANDBOX_HOST_PORT_OFFSET; if (!Number.isInteger(gatewayPort) || gatewayPort < 1 || gatewayPort > 65535 || !Number.isInteger(sandboxPort) || sandboxPort < 1 || sandboxPort > 65535 || sandboxPort === gatewayPort) throw new Error("MCP Apps require distinct valid Gateway and sandbox ports"); return sandboxPort; } function decodeSandboxHostCsp(value) { if (value === null) return; if (value.length > SANDBOX_HOST_CSP_MAX_ENCODED_BYTES) throw new Error("MCP App CSP metadata is too large"); const normalized = normalizeSandboxHostCsp(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(value, "base64url")))); if (!normalized) throw new Error("MCP App CSP metadata is not a valid policy"); return normalized; } /** Trusted outer document. Untrusted content is written only into its inner iframe. */ function buildSandboxHostProxyHtml(csp) { const blockDescendantFrames = csp?.blockDescendantFrames === true; return `<!doctype html> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <title>MCP App sandbox</title> <style>html,body{height:100%;margin:0;background:transparent}iframe{display:block;width:100%;height:100%;border:0;background:transparent}</style> <body> <script> (() => { if (window.self === window.top) throw new Error("invalid MCP App sandbox host"); let hostOrigin; try { const referrer = new URL(document.referrer); if (referrer.protocol !== "http:" && referrer.protocol !== "https:") throw new Error(); hostOrigin = referrer.origin; } catch { throw new Error("invalid MCP App sandbox parent"); } try { void window.top.document; throw new Error("MCP App sandbox isolation failed"); } catch (error) { if (error instanceof Error && error.message === "MCP App sandbox isolation failed") throw error; } const createInner = () => { const frame = document.createElement("iframe"); frame.setAttribute("sandbox", "allow-scripts allow-forms"); return frame; }; let inner = createInner(); document.body.appendChild(inner); const widgetPortsOffered = new Set(); const blockDescendantFrames = ${blockDescendantFrames}; const descendantSelector = "iframe,frame,object,embed,portal,fencedframe,webview,browser"; const hasBlockedDescendant = root => { if (root.querySelector(descendantSelector)) return true; for (const template of root.querySelectorAll("template")) { if (hasBlockedDescendant(template.content)) return true; } return false; }; const documentGuard = ${JSON.stringify(buildSandboxDocumentGuardHtml(blockDescendantFrames)).replaceAll("<", "\\u003c")}; const resolveLeadingDoctypeEnd = ${RESOLVE_LEADING_DOCTYPE_END_SOURCE}; const guardDocument = html => { if (!blockDescendantFrames) return html; if (hasBlockedDescendant(new DOMParser().parseFromString(html, "text/html"))) { throw new Error("sandbox descendant browsing contexts are disabled"); } const doctypeEnd = resolveLeadingDoctypeEnd(html); return html.slice(0, doctypeEnd) + documentGuard + html.slice(doctypeEnd); }; window.addEventListener("message", (event) => { if (event.source === window.parent) { if (event.origin !== hostOrigin) return; if (event.data?.method === "ui/notifications/sandbox-resource-ready") { const params = event.data.params ?? {}; if (typeof params.html === "string") { const guardedHtml = guardDocument(params.html); // Replace the browsing context so a superseded document cannot race // the new wrapper's first private bridge-port offer. const nextInner = createInner(); widgetPortsOffered.clear(); nextInner.srcdoc = guardedHtml; inner.replaceWith(nextInner); inner = nextInner; } return; } if (typeof event.data?.method === "string" && event.data.method.startsWith("ui/notifications/sandbox-")) return; inner.contentWindow?.postMessage(event.data, "*"); return; } if (event.source === inner.contentWindow) { if (typeof event.data?.method === "string" && event.data.method.startsWith("ui/notifications/sandbox-")) return; if (event.data?.type === "openclaw:widget-bridge-port-offer" || event.data?.type === "openclaw:widget-prompt-offer") { const port = event.ports[0]; // Each wrapper offers its private channels before untrusted code runs. // Only the first offer of each kind belongs to this document instance. if (!widgetPortsOffered.has(event.data.type) && port) { widgetPortsOffered.add(event.data.type); window.parent.postMessage(event.data, hostOrigin, [port]); } else { port?.close(); } return; } window.parent.postMessage(event.data, hostOrigin); } }); window.parent.postMessage({ jsonrpc: "2.0", method: "ui/notifications/sandbox-proxy-ready", params: { sandboxUrl: window.location.href }, }, hostOrigin); })(); <\/script> </body>`; } /** HTTP response policy for the isolated proxy and its inner about:blank content. */ function buildSandboxHostContentSecurityPolicy(csp) { const resources = csp?.resourceDomains ?? []; const connections = csp?.connectDomains ?? []; const frames = csp?.frameDomains ?? []; const bases = csp?.baseUriDomains ?? []; const sources = (values) => values.length > 0 ? values.join(" ") : "'none'"; const directives = [ "default-src 'none'", `script-src 'self' 'unsafe-inline' ${resources.join(" ")}`.trim(), `style-src 'self' 'unsafe-inline' ${resources.join(" ")}`.trim(), `img-src 'self' data: ${resources.join(" ")}`.trim(), `media-src 'self' data: ${resources.join(" ")}`.trim(), `connect-src ${sources(connections)}`, "webrtc 'block'", `frame-src ${sources(frames)}`, `base-uri ${bases.length > 0 ? bases.join(" ") : "'self'"}`, "object-src 'none'", "form-action 'none'", "frame-ancestors http: https:" ]; if (csp) directives.splice(5, 0, `font-src 'self' ${resources.join(" ")}`.trim()); return directives.join("; "); } //#endregion export { decodeSandboxHostCsp as a, buildSandboxHostProxyHtml as i, buildSandboxHostContentSecurityPolicy as n, normalizeSandboxHostCsp as o, buildSandboxHostPath as r, resolveSandboxHostPort as s, SANDBOX_HOST_PATH as t };