Skip to content

Commit f8524ec

Browse files
committed
fix(security): harden exported session html rendering
1 parent f6afc8c commit f8524ec

3 files changed

Lines changed: 278 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
1111
### Fixes
1212

1313
- 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.
14+
- 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.
1415
- 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.
1516
- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras.
1617
- 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.

src/auto-reply/reply/export-html/template.js

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,15 @@
665665
return div.innerHTML;
666666
}
667667

668+
// Validate image MIME type to prevent attribute injection in data-URL src.
669+
const SAFE_IMAGE_MIME_RE = /^image\/(png|jpeg|gif|webp|svg\+xml|bmp|tiff|avif)$/i;
670+
function sanitizeImageMimeType(mimeType) {
671+
if (typeof mimeType === "string" && SAFE_IMAGE_MIME_RE.test(mimeType)) {
672+
return mimeType;
673+
}
674+
return "application/octet-stream";
675+
}
676+
668677
/**
669678
* Truncate string to maxLen chars, append "..." if truncated.
670679
*/
@@ -722,13 +731,13 @@
722731
`<span class="tree-role-tool">${escapeHtml(formatToolCall(toolCall.name, toolCall.arguments))}</span>`
723732
);
724733
}
725-
return labelHtml + `<span class="tree-role-tool">[${msg.toolName || "tool"}]</span>`;
734+
return labelHtml + `<span class="tree-role-tool">[${escapeHtml(msg.toolName || "tool")}]</span>`;
726735
}
727736
if (msg.role === "bashExecution") {
728737
const cmd = truncate(normalize(msg.command || ""));
729738
return labelHtml + `<span class="tree-role-tool">[bash]:</span> ${escapeHtml(cmd)}`;
730739
}
731-
return labelHtml + `<span class="tree-muted">[${msg.role}]</span>`;
740+
return labelHtml + `<span class="tree-muted">[${escapeHtml(msg.role)}]</span>`;
732741
}
733742
case "compaction":
734743
return (
@@ -751,11 +760,11 @@
751760
);
752761
}
753762
case "model_change":
754-
return labelHtml + `<span class="tree-muted">[model: ${entry.modelId}]</span>`;
763+
return labelHtml + `<span class="tree-muted">[model: ${escapeHtml(entry.modelId)}]</span>`;
755764
case "thinking_level_change":
756-
return labelHtml + `<span class="tree-muted">[thinking: ${entry.thinkingLevel}]</span>`;
765+
return labelHtml + `<span class="tree-muted">[thinking: ${escapeHtml(entry.thinkingLevel)}]</span>`;
757766
default:
758-
return labelHtml + `<span class="tree-muted">[${entry.type}]</span>`;
767+
return labelHtml + `<span class="tree-muted">[${escapeHtml(entry.type)}]</span>`;
759768
}
760769
}
761770

@@ -1029,7 +1038,10 @@
10291038
return (
10301039
'<div class="tool-images">' +
10311040
images
1032-
.map((img) => `<img src="data:${img.mimeType};base64,${img.data}" class="tool-image" />`)
1041+
.map(
1042+
(img) =>
1043+
`<img src="data:${sanitizeImageMimeType(img.mimeType)};base64,${img.data}" class="tool-image" />`,
1044+
)
10331045
.join("") +
10341046
"</div>"
10351047
);
@@ -1303,7 +1315,7 @@
13031315
if (images.length > 0) {
13041316
html += '<div class="message-images">';
13051317
for (const img of images) {
1306-
html += `<img src="data:${img.mimeType};base64,${img.data}" class="message-image" />`;
1318+
html += `<img src="data:${sanitizeImageMimeType(img.mimeType)};base64,${img.data}" class="message-image" />`;
13071319
}
13081320
html += "</div>";
13091321
}
@@ -1522,7 +1534,7 @@
15221534
</div>
15231535
<div class="header-info">
15241536
<div class="info-item"><span class="info-label">Date:</span><span class="info-value">${header?.timestamp ? new Date(header.timestamp).toLocaleString() : "unknown"}</span></div>
1525-
<div class="info-item"><span class="info-label">Models:</span><span class="info-value">${globalStats.models.join(", ") || "unknown"}</span></div>
1537+
<div class="info-item"><span class="info-label">Models:</span><span class="info-value">${escapeHtml(globalStats.models.join(", ") || "unknown")}</span></div>
15261538
<div class="info-item"><span class="info-label">Messages:</span><span class="info-value">${msgParts.join(", ") || "0"}</span></div>
15271539
<div class="info-item"><span class="info-label">Tool Calls:</span><span class="info-value">${globalStats.toolCalls}</span></div>
15281540
<div class="info-item"><span class="info-label">Tokens:</span><span class="info-value">${tokenParts.join(" ") || "0"}</span></div>
@@ -1718,6 +1730,10 @@
17181730
codespan(token) {
17191731
return `<code>${escapeHtml(token.text)}</code>`;
17201732
},
1733+
// Raw HTML blocks/inline HTML: escape to prevent script execution.
1734+
html(token) {
1735+
return escapeHtml(token.text);
1736+
},
17211737
},
17221738
});
17231739

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import vm from "node:vm";
4+
import { fileURLToPath } from "node:url";
5+
import { describe, expect, it } from "vitest";
6+
import { parseHTML } from "linkedom";
7+
8+
type SessionEntry = {
9+
id: string;
10+
parentId: string | null;
11+
timestamp: string;
12+
type: string;
13+
message?: unknown;
14+
summary?: string;
15+
content?: unknown;
16+
display?: boolean;
17+
customType?: string;
18+
provider?: string;
19+
modelId?: string;
20+
thinkingLevel?: string;
21+
};
22+
23+
type SessionData = {
24+
header: { id: string; timestamp: string };
25+
entries: SessionEntry[];
26+
leafId: string;
27+
systemPrompt: string;
28+
tools: unknown[];
29+
};
30+
31+
const exportHtmlDir = path.dirname(fileURLToPath(import.meta.url));
32+
const templateHtml = fs.readFileSync(path.join(exportHtmlDir, "template.html"), "utf8");
33+
const templateJs = fs.readFileSync(path.join(exportHtmlDir, "template.js"), "utf8");
34+
const markedJs = fs.readFileSync(path.join(exportHtmlDir, "vendor", "marked.min.js"), "utf8");
35+
const highlightJs = fs.readFileSync(path.join(exportHtmlDir, "vendor", "highlight.min.js"), "utf8");
36+
37+
function renderTemplate(sessionData: SessionData) {
38+
const html = templateHtml
39+
.replace("{{CSS}}", "")
40+
.replace("{{SESSION_DATA}}", Buffer.from(JSON.stringify(sessionData), "utf8").toString("base64"))
41+
.replace("{{MARKED_JS}}", "")
42+
.replace("{{HIGHLIGHT_JS}}", "")
43+
.replace("{{JS}}", "");
44+
45+
const { document, window } = parseHTML(html);
46+
if (window.HTMLElement?.prototype) {
47+
window.HTMLElement.prototype.scrollIntoView = () => {};
48+
}
49+
50+
const immediateTimeout = (fn: (...args: unknown[]) => void) => {
51+
fn();
52+
return 0;
53+
};
54+
const runtime: Record<string, unknown> = {
55+
document,
56+
console,
57+
clearTimeout: () => {},
58+
setTimeout: immediateTimeout,
59+
URLSearchParams,
60+
TextDecoder,
61+
atob: (s: string) => Buffer.from(s, "base64").toString("binary"),
62+
btoa: (s: string) => Buffer.from(s, "binary").toString("base64"),
63+
navigator: { clipboard: { writeText: async () => {} } },
64+
history: { replaceState: () => {} },
65+
location: { href: "http://localhost/export.html", search: "" },
66+
};
67+
runtime.window = runtime;
68+
runtime.self = runtime;
69+
runtime.globalThis = runtime;
70+
71+
vm.createContext(runtime);
72+
vm.runInContext(markedJs, runtime);
73+
vm.runInContext(highlightJs, runtime);
74+
vm.runInContext(templateJs, runtime);
75+
return { document };
76+
}
77+
78+
function now() {
79+
return new Date("2026-02-24T00:00:00.000Z").toISOString();
80+
}
81+
82+
describe("export html security hardening", () => {
83+
it("escapes raw HTML from markdown blocks", () => {
84+
const attack = "<img src=x onerror=alert(1)>";
85+
const session: SessionData = {
86+
header: { id: "session-1", timestamp: now() },
87+
entries: [
88+
{
89+
id: "1",
90+
parentId: null,
91+
timestamp: now(),
92+
type: "message",
93+
message: { role: "user", content: attack },
94+
},
95+
{
96+
id: "2",
97+
parentId: "1",
98+
timestamp: now(),
99+
type: "branch_summary",
100+
summary: attack,
101+
},
102+
{
103+
id: "3",
104+
parentId: "2",
105+
timestamp: now(),
106+
type: "custom_message",
107+
customType: "x",
108+
display: true,
109+
content: attack,
110+
},
111+
],
112+
leafId: "3",
113+
systemPrompt: "",
114+
tools: [],
115+
};
116+
117+
const { document } = renderTemplate(session);
118+
const messages = document.getElementById("messages");
119+
expect(messages).toBeTruthy();
120+
expect(messages?.querySelector("img[onerror]")).toBeNull();
121+
expect(messages?.innerHTML).toContain("&lt;img src=x onerror=alert(1)&gt;");
122+
});
123+
124+
it("escapes tree and header metadata fields", () => {
125+
const attack = "<img src=x onerror=alert(9)>";
126+
const baseEntries: SessionEntry[] = [
127+
{
128+
id: "1",
129+
parentId: null,
130+
timestamp: now(),
131+
type: "message",
132+
message: { role: "user", content: "ok" },
133+
},
134+
{
135+
id: "2",
136+
parentId: "1",
137+
timestamp: now(),
138+
type: "message",
139+
message: {
140+
role: "assistant",
141+
model: attack,
142+
provider: "p",
143+
content: [{ type: "text", text: "assistant" }],
144+
},
145+
},
146+
{
147+
id: "3",
148+
parentId: "2",
149+
timestamp: now(),
150+
type: "message",
151+
message: { role: "toolResult", toolName: attack },
152+
},
153+
{
154+
id: "4",
155+
parentId: "3",
156+
timestamp: now(),
157+
type: "model_change",
158+
provider: "p",
159+
modelId: attack,
160+
},
161+
{
162+
id: "5",
163+
parentId: "4",
164+
timestamp: now(),
165+
type: "thinking_level_change",
166+
thinkingLevel: attack,
167+
},
168+
{
169+
id: "6",
170+
parentId: "5",
171+
timestamp: now(),
172+
type: attack,
173+
},
174+
];
175+
176+
const headerSession: SessionData = {
177+
header: { id: "session-2", timestamp: now() },
178+
entries: baseEntries,
179+
leafId: "6",
180+
systemPrompt: "",
181+
tools: [],
182+
};
183+
184+
const { document } = renderTemplate(headerSession);
185+
const tree = document.getElementById("tree-container");
186+
const header = document.getElementById("header-container");
187+
expect(tree).toBeTruthy();
188+
expect(header).toBeTruthy();
189+
expect(tree?.querySelector("img[onerror]")).toBeNull();
190+
expect(header?.querySelector("img[onerror]")).toBeNull();
191+
expect(tree?.innerHTML).toContain("&lt;img src=x onerror=alert(9)&gt;");
192+
expect(header?.innerHTML).toContain("&lt;img src=x onerror=alert(9)&gt;");
193+
194+
const modelLeafSession: SessionData = {
195+
header: { id: "session-2-model", timestamp: now() },
196+
entries: baseEntries,
197+
leafId: "4",
198+
systemPrompt: "",
199+
tools: [],
200+
};
201+
const modelLeaf = renderTemplate(modelLeafSession).document;
202+
expect(modelLeaf.getElementById("tree-container")?.querySelector("img[onerror]")).toBeNull();
203+
expect(modelLeaf.getElementById("tree-container")?.innerHTML).toContain(
204+
"&lt;img src=x onerror=alert(9)&gt;",
205+
);
206+
207+
const thinkingLeafSession: SessionData = {
208+
header: { id: "session-2-thinking", timestamp: now() },
209+
entries: baseEntries,
210+
leafId: "5",
211+
systemPrompt: "",
212+
tools: [],
213+
};
214+
const thinkingLeaf = renderTemplate(thinkingLeafSession).document;
215+
expect(thinkingLeaf.getElementById("tree-container")?.querySelector("img[onerror]")).toBeNull();
216+
expect(thinkingLeaf.getElementById("tree-container")?.innerHTML).toContain(
217+
"&lt;img src=x onerror=alert(9)&gt;",
218+
);
219+
});
220+
221+
it("sanitizes image MIME types used in data URLs", () => {
222+
const session: SessionData = {
223+
header: { id: "session-3", timestamp: now() },
224+
entries: [
225+
{
226+
id: "1",
227+
parentId: null,
228+
timestamp: now(),
229+
type: "message",
230+
message: {
231+
role: "user",
232+
content: [
233+
{
234+
type: "image",
235+
data: "AAAA",
236+
mimeType: 'image/png" onerror="alert(7)',
237+
},
238+
],
239+
},
240+
},
241+
],
242+
leafId: "1",
243+
systemPrompt: "",
244+
tools: [],
245+
};
246+
247+
const { document } = renderTemplate(session);
248+
const img = document.querySelector("#messages .message-image");
249+
expect(img).toBeTruthy();
250+
expect(img?.getAttribute("onerror")).toBeNull();
251+
expect(img?.getAttribute("src")).toBe("data:application/octet-stream;base64,AAAA");
252+
});
253+
});

0 commit comments

Comments
 (0)