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 ( '
${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