mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 07:11:39 +00:00
fix(security): harden exported session html rendering
This commit is contained in:
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
### Fixes
|
### 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/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.
|
- 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.
|
- 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.
|
- 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;
|
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.
|
* Truncate string to maxLen chars, append "..." if truncated.
|
||||||
*/
|
*/
|
||||||
@@ -722,13 +731,13 @@
|
|||||||
`<span class="tree-role-tool">${escapeHtml(formatToolCall(toolCall.name, toolCall.arguments))}</span>`
|
`<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") {
|
if (msg.role === "bashExecution") {
|
||||||
const cmd = truncate(normalize(msg.command || ""));
|
const cmd = truncate(normalize(msg.command || ""));
|
||||||
return labelHtml + `<span class="tree-role-tool">[bash]:</span> ${escapeHtml(cmd)}`;
|
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":
|
case "compaction":
|
||||||
return (
|
return (
|
||||||
@@ -751,11 +760,11 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "model_change":
|
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":
|
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:
|
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 (
|
return (
|
||||||
'<div class="tool-images">' +
|
'<div class="tool-images">' +
|
||||||
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("") +
|
.join("") +
|
||||||
"</div>"
|
"</div>"
|
||||||
);
|
);
|
||||||
@@ -1303,7 +1315,7 @@
|
|||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
html += '<div class="message-images">';
|
html += '<div class="message-images">';
|
||||||
for (const img of 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>";
|
html += "</div>";
|
||||||
}
|
}
|
||||||
@@ -1522,7 +1534,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-info">
|
<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">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">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">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>
|
<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) {
|
codespan(token) {
|
||||||
return `<code>${escapeHtml(token.text)}</code>`;
|
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