diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index e460b2630..55be0dba6 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -229,4 +229,37 @@ describe("canvas host", () => { await fs.rm(dir, { recursive: true, force: true }); } }); + + it("default index.html uses safe DOM manipulation to prevent XSS", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-canvas-")); + + const server = await startCanvasHost({ + runtime: defaultRuntime, + rootDir: dir, + port: 0, + listenHost: "127.0.0.1", + allowInTests: true, + liveReload: false, + }); + + try { + const res = await fetch(`http://127.0.0.1:${server.port}${CANVAS_HOST_PATH}/`); + const html = await res.text(); + expect(res.status).toBe(200); + + // Verify the HTML uses safe DOM manipulation methods + expect(html).toContain("document.createElement"); + expect(html).toContain("textContent"); + + // Verify dangerous innerHTML assignment for status is not present + expect(html).not.toContain("statusEl.innerHTML"); + + // Verify the status element construction is present + expect(html).toContain("bridgeSpan.className"); + expect(html).toContain("bridgeSpan.textContent"); + } finally { + await server.close(); + await fs.rm(dir, { recursive: true, force: true }); + } + }); }); diff --git a/src/canvas-host/server.ts b/src/canvas-host/server.ts index 11e1ccec1..b63a2de6d 100644 --- a/src/canvas-host/server.ts +++ b/src/canvas-host/server.ts @@ -122,11 +122,17 @@ function defaultIndexHTML() { const hasHelper = () => typeof window.moltbotSendUserAction === "function" || typeof window.clawdbotSendUserAction === "function"; - statusEl.innerHTML = - "Bridge: " + - (hasHelper() ? "ready" : "missing") + - " · iOS=" + (hasIOS() ? "yes" : "no") + - " · Android=" + (hasAndroid() ? "yes" : "no"); + // Build status message safely using DOM manipulation to prevent XSS + statusEl.textContent = ""; + statusEl.appendChild(document.createTextNode("Bridge: ")); + + const bridgeSpan = document.createElement("span"); + bridgeSpan.className = hasHelper() ? "ok" : "bad"; + bridgeSpan.textContent = hasHelper() ? "ready" : "missing"; + statusEl.appendChild(bridgeSpan); + + statusEl.appendChild(document.createTextNode(" · iOS=" + (hasIOS() ? "yes" : "no"))); + statusEl.appendChild(document.createTextNode(" · Android=" + (hasAndroid() ? "yes" : "no"))); window.addEventListener("moltbot:a2ui-action-status", (ev) => { const d = ev && ev.detail || {}; diff --git a/vitest.config.ts b/vitest.config.ts index 92c962a1f..65195f825 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,9 @@ const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; const isWindows = process.platform === "win32"; const localWorkers = Math.max(4, Math.min(16, os.cpus().length)); const ciWorkers = isWindows ? 2 : 3; +// Use single fork on Windows CI to prevent "Worker exited unexpectedly" errors +// caused by unstable child process handling in Windows GitHub Actions runners +const useSingleFork = isCI && isWindows; export default defineConfig({ resolve: { @@ -19,6 +22,12 @@ export default defineConfig({ testTimeout: 120_000, hookTimeout: isWindows ? 180_000 : 120_000, pool: "forks", + poolOptions: { + forks: { + // Single fork on Windows CI prevents "Worker exited unexpectedly" errors + singleFork: useSingleFork, + }, + }, maxWorkers: isCI ? ciWorkers : localWorkers, include: [ "src/**/*.test.ts",