Skip to content

Commit 7bc4a33

Browse files
authored
fix(security): escape entry.id in HTML export to prevent attribute XSS (#83104)
* fix(security): escape entry.id in HTML export to prevent attribute XSS Apply escapeHtmlAttr to entry.id in renderEntry and renderCopyLinkButton to prevent attribute injection via crafted entry IDs in HTML exports. Signed-off-by: Sebastien Tardif <[email protected]> * chore: remove proof helper scripts from branch ClawSweeper P2: committed proof scripts can provide false-positive validation. Proof output is in the PR body instead. Signed-off-by: Sebastien Tardif <[email protected]> --------- Signed-off-by: Sebastien Tardif <[email protected]>
1 parent 76a025c commit 7bc4a33

2 files changed

Lines changed: 100 additions & 2 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,7 +1309,7 @@
13091309
* Render the copy-link button HTML for a message.
13101310
*/
13111311
function renderCopyLinkButton(entryId) {
1312-
return `<button class="copy-link-btn" data-entry-id="${entryId}" title="Copy link to this message">
1312+
return `<button class="copy-link-btn" data-entry-id="${escapeHtmlAttr(entryId)}" title="Copy link to this message">
13131313
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
13141314
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
13151315
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
@@ -1320,7 +1320,7 @@
13201320
function renderEntry(entry) {
13211321
const ts = formatTimestamp(entry.timestamp);
13221322
const tsHtml = ts ? `<div class="message-timestamp">${ts}</div>` : "";
1323-
const entryId = `entry-${entry.id}`;
1323+
const entryId = `entry-${escapeHtmlAttr(entry.id)}`;
13241324
const copyBtnHtml = renderCopyLinkButton(entry.id);
13251325

13261326
if (entry.type === "message") {

src/auto-reply/reply/export-html/template.security.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,104 @@ describe("export html security hardening", () => {
494494
);
495495
});
496496

497+
it("escapes entry.id in element id and data-entry-id attributes", async () => {
498+
const xssId = `"><script>alert(1)</script><div data-x="`;
499+
const session: SessionData = {
500+
header: { id: "session-xss-id", timestamp: now() },
501+
entries: [
502+
{
503+
id: xssId,
504+
parentId: null,
505+
timestamp: now(),
506+
type: "message",
507+
message: { role: "user", content: "hello" },
508+
},
509+
{
510+
id: "safe-child",
511+
parentId: xssId,
512+
timestamp: now(),
513+
type: "message",
514+
message: {
515+
role: "assistant",
516+
content: [{ type: "text", text: "world" }],
517+
},
518+
},
519+
],
520+
leafId: "safe-child",
521+
systemPrompt: "",
522+
tools: [],
523+
};
524+
525+
const { document } = await renderTemplate(session);
526+
const messages = requireElement(document.getElementById("messages"), "messages root missing");
527+
528+
// Core XSS prevention: no <script> tags should be injected into the DOM
529+
expect(messages.querySelectorAll("script").length).toBe(0);
530+
531+
// No attribute breakout: onmouseover, data-x must not appear as real attributes
532+
expect(messages.querySelector("[onmouseover]")).toBeNull();
533+
534+
// The copy-link button must exist with the payload safely contained
535+
const copyBtn = requireElement(
536+
messages.querySelector(".copy-link-btn"),
537+
"copy-link button missing",
538+
);
539+
// data-entry-id must be present as a proper attribute (not broken out)
540+
expect(copyBtn.hasAttribute("data-entry-id")).toBe(true);
541+
// No stray attributes from the payload
542+
expect(copyBtn.hasAttribute("data-x")).toBe(false);
543+
544+
// The user message element must not have attribute breakout either
545+
const userMsg = requireElement(
546+
messages.querySelector(".user-message"),
547+
"user message element missing",
548+
);
549+
expect(userMsg.getAttribute("data-x")).toBeNull();
550+
// The element id must start with entry- (the payload is contained within)
551+
const elementId = userMsg.getAttribute("id") ?? "";
552+
expect(elementId.startsWith("entry-")).toBe(true);
553+
});
554+
555+
it("copy-link round-trip: dataset.entryId matches raw entry.id after browser decoding", async () => {
556+
// IDs with characters that need HTML escaping but should round-trip correctly
557+
const specialId = `msg-with"quotes&amp's`;
558+
const session: SessionData = {
559+
header: { id: "session-roundtrip", timestamp: now() },
560+
entries: [
561+
{
562+
id: specialId,
563+
parentId: null,
564+
timestamp: now(),
565+
type: "message",
566+
message: { role: "user", content: "test" },
567+
},
568+
],
569+
leafId: specialId,
570+
systemPrompt: "",
571+
tools: [],
572+
};
573+
574+
const { document } = await renderTemplate(session);
575+
const messages = requireElement(document.getElementById("messages"), "messages root missing");
576+
577+
// The copy-link button should exist
578+
const copyBtn = requireElement(
579+
messages.querySelector(".copy-link-btn"),
580+
"copy-link button missing",
581+
);
582+
583+
// Browser decodes HTML entities in dataset reads, so dataset.entryId
584+
// must return the RAW entry.id (not the HTML-escaped version).
585+
// This is essential for buildShareUrl() to produce the correct URL.
586+
const datasetValue = (copyBtn as HTMLElement).dataset.entryId;
587+
expect(datasetValue).toBe(specialId);
588+
589+
// The DOM element id must also round-trip: getElementById should find it
590+
const userMsg = document.getElementById(`entry-${specialId}`);
591+
expect(userMsg).not.toBeNull();
592+
expect(userMsg?.classList.contains("user-message")).toBe(true);
593+
});
594+
497595
it("escapes markdown data-image attributes", async () => {
498596
const dataImage = "data:image/png;base64,AAAA";
499597
const session: SessionData = {

0 commit comments

Comments
 (0)