fix(security): harden exported session html rendering

This commit is contained in:
Peter Steinberger
2026-02-24 02:40:03 +00:00
parent f6afc8c5b6
commit f8524ec77a
3 changed files with 278 additions and 8 deletions

View File

@@ -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.

View File

@@ -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 @@
`<span class="tree-role-tool">${escapeHtml(formatToolCall(toolCall.name, toolCall.arguments))}</span>`
);
}
return labelHtml + `<span class="tree-role-tool">[${msg.toolName || "tool"}]</span>`;
return labelHtml + `<span class="tree-role-tool">[${escapeHtml(msg.toolName || "tool")}]</span>`;
}
if (msg.role === "bashExecution") {
const cmd = truncate(normalize(msg.command || ""));
return labelHtml + `<span class="tree-role-tool">[bash]:</span> ${escapeHtml(cmd)}`;
}
return labelHtml + `<span class="tree-muted">[${msg.role}]</span>`;
return labelHtml + `<span class="tree-muted">[${escapeHtml(msg.role)}]</span>`;
}
case "compaction":
return (
@@ -751,11 +760,11 @@
);
}
case "model_change":
return labelHtml + `<span class="tree-muted">[model: ${entry.modelId}]</span>`;
return labelHtml + `<span class="tree-muted">[model: ${escapeHtml(entry.modelId)}]</span>`;
case "thinking_level_change":
return labelHtml + `<span class="tree-muted">[thinking: ${entry.thinkingLevel}]</span>`;
return labelHtml + `<span class="tree-muted">[thinking: ${escapeHtml(entry.thinkingLevel)}]</span>`;
default:
return labelHtml + `<span class="tree-muted">[${entry.type}]</span>`;
return labelHtml + `<span class="tree-muted">[${escapeHtml(entry.type)}]</span>`;
}
}
@@ -1029,7 +1038,10 @@
return (
'<div class="tool-images">' +
images
.map((img) => `<img src="data:${img.mimeType};base64,${img.data}" class="tool-image" />`)
.map(
(img) =>
`<img src="data:${sanitizeImageMimeType(img.mimeType)};base64,${img.data}" class="tool-image" />`,
)
.join("") +
"</div>"
);
@@ -1303,7 +1315,7 @@
if (images.length > 0) {
html += '<div class="message-images">';
for (const img of images) {
html += `<img src="data:${img.mimeType};base64,${img.data}" class="message-image" />`;
html += `<img src="data:${sanitizeImageMimeType(img.mimeType)};base64,${img.data}" class="message-image" />`;
}
html += "</div>";
}
@@ -1522,7 +1534,7 @@
</div>
<div class="header-info">
<div class="info-item"><span class="info-label">Date:</span><span class="info-value">${header?.timestamp ? new Date(header.timestamp).toLocaleString() : "unknown"}</span></div>
<div class="info-item"><span class="info-label">Models:</span><span class="info-value">${globalStats.models.join(", ") || "unknown"}</span></div>
<div class="info-item"><span class="info-label">Models:</span><span class="info-value">${escapeHtml(globalStats.models.join(", ") || "unknown")}</span></div>
<div class="info-item"><span class="info-label">Messages:</span><span class="info-value">${msgParts.join(", ") || "0"}</span></div>
<div class="info-item"><span class="info-label">Tool Calls:</span><span class="info-value">${globalStats.toolCalls}</span></div>
<div class="info-item"><span class="info-label">Tokens:</span><span class="info-value">${tokenParts.join(" ") || "0"}</span></div>
@@ -1718,6 +1730,10 @@
codespan(token) {
return `<code>${escapeHtml(token.text)}</code>`;
},
// Raw HTML blocks/inline HTML: escape to prevent script execution.
html(token) {
return escapeHtml(token.text);
},
},
});

View File

@@ -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<string, unknown> = {
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 = "<img src=x onerror=alert(1)>";
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("&lt;img src=x onerror=alert(1)&gt;");
});
it("escapes tree and header metadata fields", () => {
const attack = "<img src=x onerror=alert(9)>";
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("&lt;img src=x onerror=alert(9)&gt;");
expect(header?.innerHTML).toContain("&lt;img src=x onerror=alert(9)&gt;");
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(
"&lt;img src=x onerror=alert(9)&gt;",
);
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(
"&lt;img src=x onerror=alert(9)&gt;",
);
});
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");
});
});