mirror of https://github.com/openclaw/openclaw.git
322 lines
9.2 KiB
TypeScript
322 lines
9.2 KiB
TypeScript
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");
|
|
});
|
|
|
|
it("flattens remote markdown images but keeps data-image markdown", () => {
|
|
const dataImage = "data:image/png;base64,AAAA";
|
|
const session: SessionData = {
|
|
header: { id: "session-4", timestamp: now() },
|
|
entries: [
|
|
{
|
|
id: "1",
|
|
parentId: null,
|
|
timestamp: now(),
|
|
type: "message",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Leak:\n\n\n\n`,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
leafId: "1",
|
|
systemPrompt: "",
|
|
tools: [],
|
|
};
|
|
|
|
const { document } = renderTemplate(session);
|
|
const messages = document.getElementById("messages");
|
|
expect(messages).toBeTruthy();
|
|
expect(messages?.querySelector('img[src^="https://"]')).toBeNull();
|
|
expect(messages?.textContent).toContain("exfil");
|
|
expect(messages?.querySelector(`img[src="${dataImage}"]`)).toBeTruthy();
|
|
});
|
|
|
|
it("escapes markdown data-image attributes", () => {
|
|
const dataImage = "data:image/png;base64,AAAA";
|
|
const session: SessionData = {
|
|
header: { id: "session-5", timestamp: now() },
|
|
entries: [
|
|
{
|
|
id: "1",
|
|
parentId: null,
|
|
timestamp: now(),
|
|
type: "message",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: ``,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
leafId: "1",
|
|
systemPrompt: "",
|
|
tools: [],
|
|
};
|
|
|
|
const { document } = renderTemplate(session);
|
|
const img = document.querySelector("#messages img");
|
|
expect(img).toBeTruthy();
|
|
expect(img?.getAttribute("onerror")).toBeNull();
|
|
expect(img?.getAttribute("alt")).toBe('x" onerror="alert(1)');
|
|
expect(img?.getAttribute("src")).toBe(dataImage);
|
|
});
|
|
});
|