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",