diff --git a/CHANGELOG.md b/CHANGELOG.md index 1abcdb4a8c3..fc5e5934250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. +- Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. - Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/src/auto-reply/reply/export-html/template.js b/src/auto-reply/reply/export-html/template.js index f4f19a6d25d..318751fde53 100644 --- a/src/auto-reply/reply/export-html/template.js +++ b/src/auto-reply/reply/export-html/template.js @@ -665,6 +665,15 @@ return div.innerHTML; } + // Validate image MIME type to prevent attribute injection in data-URL src. + const SAFE_IMAGE_MIME_RE = /^image\/(png|jpeg|gif|webp|svg\+xml|bmp|tiff|avif)$/i; + function sanitizeImageMimeType(mimeType) { + if (typeof mimeType === "string" && SAFE_IMAGE_MIME_RE.test(mimeType)) { + return mimeType; + } + return "application/octet-stream"; + } + /** * Truncate string to maxLen chars, append "..." if truncated. */ @@ -722,13 +731,13 @@ `${escapeHtml(formatToolCall(toolCall.name, toolCall.arguments))}` ); } - return labelHtml + `[${msg.toolName || "tool"}]`; + return labelHtml + `[${escapeHtml(msg.toolName || "tool")}]`; } if (msg.role === "bashExecution") { const cmd = truncate(normalize(msg.command || "")); return labelHtml + `[bash]: ${escapeHtml(cmd)}`; } - return labelHtml + `[${msg.role}]`; + return labelHtml + `[${escapeHtml(msg.role)}]`; } case "compaction": return ( @@ -751,11 +760,11 @@ ); } case "model_change": - return labelHtml + `[model: ${entry.modelId}]`; + return labelHtml + `[model: ${escapeHtml(entry.modelId)}]`; case "thinking_level_change": - return labelHtml + `[thinking: ${entry.thinkingLevel}]`; + return labelHtml + `[thinking: ${escapeHtml(entry.thinkingLevel)}]`; default: - return labelHtml + `[${entry.type}]`; + return labelHtml + `[${escapeHtml(entry.type)}]`; } } @@ -1029,7 +1038,10 @@ return ( '
' + images - .map((img) => ``) + .map( + (img) => + ``, + ) .join("") + "
" ); @@ -1303,7 +1315,7 @@ if (images.length > 0) { html += '
'; for (const img of images) { - html += ``; + html += ``; } html += "
"; } @@ -1522,7 +1534,7 @@
Date:${header?.timestamp ? new Date(header.timestamp).toLocaleString() : "unknown"}
-
Models:${globalStats.models.join(", ") || "unknown"}
+
Models:${escapeHtml(globalStats.models.join(", ") || "unknown")}
Messages:${msgParts.join(", ") || "0"}
Tool Calls:${globalStats.toolCalls}
Tokens:${tokenParts.join(" ") || "0"}
@@ -1718,6 +1730,10 @@ codespan(token) { return `${escapeHtml(token.text)}`; }, + // Raw HTML blocks/inline HTML: escape to prevent script execution. + html(token) { + return escapeHtml(token.text); + }, }, }); diff --git a/src/auto-reply/reply/export-html/template.security.test.ts b/src/auto-reply/reply/export-html/template.security.test.ts new file mode 100644 index 00000000000..2837df7036b --- /dev/null +++ b/src/auto-reply/reply/export-html/template.security.test.ts @@ -0,0 +1,253 @@ +import fs from "node:fs"; +import path from "node:path"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { parseHTML } from "linkedom"; + +type SessionEntry = { + id: string; + parentId: string | null; + timestamp: string; + type: string; + message?: unknown; + summary?: string; + content?: unknown; + display?: boolean; + customType?: string; + provider?: string; + modelId?: string; + thinkingLevel?: string; +}; + +type SessionData = { + header: { id: string; timestamp: string }; + entries: SessionEntry[]; + leafId: string; + systemPrompt: string; + tools: unknown[]; +}; + +const exportHtmlDir = path.dirname(fileURLToPath(import.meta.url)); +const templateHtml = fs.readFileSync(path.join(exportHtmlDir, "template.html"), "utf8"); +const templateJs = fs.readFileSync(path.join(exportHtmlDir, "template.js"), "utf8"); +const markedJs = fs.readFileSync(path.join(exportHtmlDir, "vendor", "marked.min.js"), "utf8"); +const highlightJs = fs.readFileSync(path.join(exportHtmlDir, "vendor", "highlight.min.js"), "utf8"); + +function renderTemplate(sessionData: SessionData) { + const html = templateHtml + .replace("{{CSS}}", "") + .replace("{{SESSION_DATA}}", Buffer.from(JSON.stringify(sessionData), "utf8").toString("base64")) + .replace("{{MARKED_JS}}", "") + .replace("{{HIGHLIGHT_JS}}", "") + .replace("{{JS}}", ""); + + const { document, window } = parseHTML(html); + if (window.HTMLElement?.prototype) { + window.HTMLElement.prototype.scrollIntoView = () => {}; + } + + const immediateTimeout = (fn: (...args: unknown[]) => void) => { + fn(); + return 0; + }; + const runtime: Record = { + document, + console, + clearTimeout: () => {}, + setTimeout: immediateTimeout, + URLSearchParams, + TextDecoder, + atob: (s: string) => Buffer.from(s, "base64").toString("binary"), + btoa: (s: string) => Buffer.from(s, "binary").toString("base64"), + navigator: { clipboard: { writeText: async () => {} } }, + history: { replaceState: () => {} }, + location: { href: "http://localhost/export.html", search: "" }, + }; + runtime.window = runtime; + runtime.self = runtime; + runtime.globalThis = runtime; + + vm.createContext(runtime); + vm.runInContext(markedJs, runtime); + vm.runInContext(highlightJs, runtime); + vm.runInContext(templateJs, runtime); + return { document }; +} + +function now() { + return new Date("2026-02-24T00:00:00.000Z").toISOString(); +} + +describe("export html security hardening", () => { + it("escapes raw HTML from markdown blocks", () => { + const attack = ""; + const session: SessionData = { + header: { id: "session-1", timestamp: now() }, + entries: [ + { + id: "1", + parentId: null, + timestamp: now(), + type: "message", + message: { role: "user", content: attack }, + }, + { + id: "2", + parentId: "1", + timestamp: now(), + type: "branch_summary", + summary: attack, + }, + { + id: "3", + parentId: "2", + timestamp: now(), + type: "custom_message", + customType: "x", + display: true, + content: attack, + }, + ], + leafId: "3", + systemPrompt: "", + tools: [], + }; + + const { document } = renderTemplate(session); + const messages = document.getElementById("messages"); + expect(messages).toBeTruthy(); + expect(messages?.querySelector("img[onerror]")).toBeNull(); + expect(messages?.innerHTML).toContain("<img src=x onerror=alert(1)>"); + }); + + it("escapes tree and header metadata fields", () => { + const attack = ""; + const baseEntries: SessionEntry[] = [ + { + id: "1", + parentId: null, + timestamp: now(), + type: "message", + message: { role: "user", content: "ok" }, + }, + { + id: "2", + parentId: "1", + timestamp: now(), + type: "message", + message: { + role: "assistant", + model: attack, + provider: "p", + content: [{ type: "text", text: "assistant" }], + }, + }, + { + id: "3", + parentId: "2", + timestamp: now(), + type: "message", + message: { role: "toolResult", toolName: attack }, + }, + { + id: "4", + parentId: "3", + timestamp: now(), + type: "model_change", + provider: "p", + modelId: attack, + }, + { + id: "5", + parentId: "4", + timestamp: now(), + type: "thinking_level_change", + thinkingLevel: attack, + }, + { + id: "6", + parentId: "5", + timestamp: now(), + type: attack, + }, + ]; + + const headerSession: SessionData = { + header: { id: "session-2", timestamp: now() }, + entries: baseEntries, + leafId: "6", + systemPrompt: "", + tools: [], + }; + + const { document } = renderTemplate(headerSession); + const tree = document.getElementById("tree-container"); + const header = document.getElementById("header-container"); + expect(tree).toBeTruthy(); + expect(header).toBeTruthy(); + expect(tree?.querySelector("img[onerror]")).toBeNull(); + expect(header?.querySelector("img[onerror]")).toBeNull(); + expect(tree?.innerHTML).toContain("<img src=x onerror=alert(9)>"); + expect(header?.innerHTML).toContain("<img src=x onerror=alert(9)>"); + + const modelLeafSession: SessionData = { + header: { id: "session-2-model", timestamp: now() }, + entries: baseEntries, + leafId: "4", + systemPrompt: "", + tools: [], + }; + const modelLeaf = renderTemplate(modelLeafSession).document; + expect(modelLeaf.getElementById("tree-container")?.querySelector("img[onerror]")).toBeNull(); + expect(modelLeaf.getElementById("tree-container")?.innerHTML).toContain( + "<img src=x onerror=alert(9)>", + ); + + const thinkingLeafSession: SessionData = { + header: { id: "session-2-thinking", timestamp: now() }, + entries: baseEntries, + leafId: "5", + systemPrompt: "", + tools: [], + }; + const thinkingLeaf = renderTemplate(thinkingLeafSession).document; + expect(thinkingLeaf.getElementById("tree-container")?.querySelector("img[onerror]")).toBeNull(); + expect(thinkingLeaf.getElementById("tree-container")?.innerHTML).toContain( + "<img src=x onerror=alert(9)>", + ); + }); + + it("sanitizes image MIME types used in data URLs", () => { + const session: SessionData = { + header: { id: "session-3", timestamp: now() }, + entries: [ + { + id: "1", + parentId: null, + timestamp: now(), + type: "message", + message: { + role: "user", + content: [ + { + type: "image", + data: "AAAA", + mimeType: 'image/png" onerror="alert(7)', + }, + ], + }, + }, + ], + leafId: "1", + systemPrompt: "", + tools: [], + }; + + const { document } = renderTemplate(session); + const img = document.querySelector("#messages .message-image"); + expect(img).toBeTruthy(); + expect(img?.getAttribute("onerror")).toBeNull(); + expect(img?.getAttribute("src")).toBe("data:application/octet-stream;base64,AAAA"); + }); +});