mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
fix(security): harden exported session html rendering
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
253
src/auto-reply/reply/export-html/template.security.test.ts
Normal file
253
src/auto-reply/reply/export-html/template.security.test.ts
Normal 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("<img src=x onerror=alert(1)>");
|
||||
});
|
||||
|
||||
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("<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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user