From f0da0f74c491ec185fd9636fa83dc9f010d13df8 Mon Sep 17 00:00:00 2001 From: 0utsights Date: Sat, 18 Jul 2026 14:43:38 -0400 Subject: [PATCH] fix(web): preserve literal user markup for PR #4133 Render user-authored XML-like source as escaped text while retaining sanitized assistant HTML, with regression coverage for custom tags, code, comparisons, and unsafe input. Co-authored-by: codex --- apps/web/src/components/ChatMarkdown.tsx | 9 +- .../components/chat/MessagesTimeline.test.tsx | 126 ++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 4 + 3 files changed, 138 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c85340c715b..b4a83ad96e6 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -115,6 +115,8 @@ interface ChatMarkdownProps { className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; + /** Parse sanitized raw HTML instead of displaying its source text. */ + parseRawHtml?: boolean; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; @@ -1256,6 +1258,7 @@ function ChatMarkdown({ skills = EMPTY_MARKDOWN_SKILLS, className, lineBreaks = false, + parseRawHtml = true, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { @@ -1554,6 +1557,9 @@ function ChatMarkdown({ ], ); + // react-markdown converts unparsed HTML nodes to text when skipHtml is false. + // Keep that behavior explicit because literal mode depends on escaping the + // complete source token instead of dropping it from the rendered message. return (
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index b340a248fbe..2276defa24b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -218,6 +218,17 @@ function buildUserTimelineEntry(text: string) { }; } +function buildAssistantTimelineEntry(text: string) { + const entry = buildUserTimelineEntry(text); + return { + ...entry, + message: { + ...entry.message, + role: "assistant" as const, + }, + }; +} + describe("MessagesTimeline", () => { it("keeps assistant changed-files headers sticky below the thread header", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); @@ -414,6 +425,121 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-user-message-collapsible="false"'); }); + it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ', + 'Before inside after', + " in your context?", + "Comparison: 2 < 3 and 5 > 4.", + ].join("\n"), + ), + ]} + />, + ); + + expect(markup).toContain("<global-agent-instructions scope="workspace">"); + expect(markup).toContain( + "Before <nested data-value="a&b">inside</nested> after", + ); + expect(markup).toContain("</global-agent-instructions> in your context?"); + expect(markup).toContain("Comparison: 2 < 3 and 5 > 4."); + }); + + it("preserves XML-like source inside user code spans and fences", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + `', + "", + "```xml", + '', + "```", + ].join("\n"), + ), + ]} + />, + ); + + expect(markup).toContain("<tag attr="x">"); + expect(markup).toContain("<root><child enabled="true" /></root>"); + }); + + it("renders unsafe user HTML as inert source text", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + globalThis.__t3Xss = 1', + ), + ]} + />, + ); + + expect(markup).toContain("<script>globalThis.__t3Xss = 1</script>"); + expect(markup).toContain( + "<img src="x" onerror="globalThis.__t3Xss = 2">", + ); + expect(markup).not.toMatch(/)/i); + expect(markup).not.toMatch(/)/i); + }); + + it("continues to render sanitized raw HTML in assistant messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + MoreDetails"), + ]} + />, + ); + + expect(markup).toContain('data-markdown-details=""'); + expect(markup).toContain("More"); + expect(markup).not.toContain("<details>"); + }); + + it("sanitizes executable HTML while preserving supported assistant markup", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ', + "Safe details", + "", + '', + 'Unsafe link', + "", + ].join(""), + ), + ]} + />, + ); + + expect(markup).toContain('data-markdown-details=""'); + expect(markup).toContain("Safe details"); + expect(markup).not.toMatch(/)/i); + expect(markup).not.toContain("onclick="); + expect(markup).not.toContain("onerror="); + expect(markup).not.toContain("javascript:"); + expect(markup).not.toContain("globalThis.__t3Xss"); + }); + it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f759aa150be..9804c072ed9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1553,6 +1553,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-foreground" lineBreaks + parseRawHtml={false} /> ) : null} {trailingWhitespace ? : null} @@ -1575,6 +1576,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-foreground" lineBreaks + parseRawHtml={false} />
) : null @@ -1663,6 +1665,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-foreground" lineBreaks + parseRawHtml={false} />, ); } else if (inlinePrefix.length === 0) { @@ -1688,6 +1691,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-foreground" lineBreaks + parseRawHtml={false} /> ); });