Skip to content

Commit 6a9a0e8

Browse files
0utsightscodex
authored andcommitted
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 <codex@users.noreply.github.com>
1 parent b7dbbba commit 6a9a0e8

3 files changed

Lines changed: 140 additions & 2 deletions

File tree

apps/web/src/components/ChatMarkdown.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ interface ChatMarkdownProps {
117117
className?: string;
118118
/** Treat single newlines as hard breaks — chat-style user input. */
119119
lineBreaks?: boolean;
120+
/** Parse sanitized raw HTML instead of displaying its source text. */
121+
parseRawHtml?: boolean;
120122
}
121123

122124
const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
@@ -1360,6 +1362,7 @@ function ChatMarkdown({
13601362
skills = EMPTY_MARKDOWN_SKILLS,
13611363
className,
13621364
lineBreaks = false,
1365+
parseRawHtml = true,
13631366
}: ChatMarkdownProps) {
13641367
const { resolvedTheme } = useTheme();
13651368
const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
@@ -1777,6 +1780,9 @@ function ChatMarkdown({
17771780
]);
17781781
/* eslint-enable react/no-unstable-nested-components */
17791782

1783+
// react-markdown converts unparsed HTML nodes to text when skipHtml is false.
1784+
// Keep that behavior explicit because literal mode depends on escaping the
1785+
// complete source token instead of dropping it from the rendered message.
17801786
return (
17811787
<div
17821788
className={cn(
@@ -1789,7 +1795,8 @@ function ChatMarkdown({
17891795
remarkPlugins={
17901796
lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS
17911797
}
1792-
rehypePlugins={CHAT_MARKDOWN_REHYPE_PLUGINS}
1798+
rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined}
1799+
skipHtml={false}
17931800
components={markdownComponents}
17941801
urlTransform={markdownUrlTransform}
17951802
>

apps/web/src/components/chat/MessagesTimeline.test.tsx

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,17 @@ function buildUserTimelineEntry(text: string) {
225225
};
226226
}
227227

228+
function buildAssistantTimelineEntry(text: string) {
229+
const entry = buildUserTimelineEntry(text);
230+
return {
231+
...entry,
232+
message: {
233+
...entry.message,
234+
role: "assistant" as const,
235+
},
236+
};
237+
}
238+
228239
describe("MessagesTimeline", () => {
229240
it("uses the larger leading inset only when the top fade is enabled", () => {
230241
const timelineEntries = [buildUserTimelineEntry("Hello")];
@@ -464,7 +475,123 @@ describe("MessagesTimeline", () => {
464475
expect(markup).toContain("rounded-2xl bg-message p-3");
465476
});
466477

467-
it("renders inline terminal labels with the composer chip UI", () => {
478+
it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => {
479+
const { MessagesTimeline } = await import("./MessagesTimeline");
480+
const markup = renderToStaticMarkup(
481+
<MessagesTimeline
482+
{...buildProps()}
483+
timelineEntries={[
484+
buildUserTimelineEntry(
485+
[
486+
'Without reading a file, do you have <global-agent-instructions scope="workspace">',
487+
'Before <nested data-value="a&b">inside</nested> after',
488+
"</global-agent-instructions> in your context?",
489+
"Comparison: 2 < 3 and 5 > 4.",
490+
].join("\n"),
491+
),
492+
]}
493+
/>,
494+
);
495+
496+
expect(markup).toContain("&lt;global-agent-instructions scope=&quot;workspace&quot;&gt;");
497+
expect(markup).toContain(
498+
"Before &lt;nested data-value=&quot;a&amp;b&quot;&gt;inside&lt;/nested&gt; after",
499+
);
500+
expect(markup).toContain("&lt;/global-agent-instructions&gt; in your context?");
501+
expect(markup).toContain("Comparison: 2 &lt; 3 and 5 &gt; 4.");
502+
});
503+
504+
it("preserves XML-like source inside user code spans and fences", async () => {
505+
const { MessagesTimeline } = await import("./MessagesTimeline");
506+
const markup = renderToStaticMarkup(
507+
<MessagesTimeline
508+
{...buildProps()}
509+
timelineEntries={[
510+
buildUserTimelineEntry(
511+
[
512+
'Inline `<tag attr="x">`',
513+
"",
514+
"```xml",
515+
'<root><child enabled="true" /></root>',
516+
"```",
517+
].join("\n"),
518+
),
519+
]}
520+
/>,
521+
);
522+
523+
expect(markup).toContain('<code data-inline-code="">&lt;tag attr=&quot;x&quot;&gt;</code>');
524+
expect(markup).toContain("&lt;root&gt;&lt;child enabled=&quot;true&quot; /&gt;&lt;/root&gt;");
525+
});
526+
527+
it("renders unsafe user HTML as inert source text", async () => {
528+
const { MessagesTimeline } = await import("./MessagesTimeline");
529+
const markup = renderToStaticMarkup(
530+
<MessagesTimeline
531+
{...buildProps()}
532+
timelineEntries={[
533+
buildUserTimelineEntry(
534+
'<script>globalThis.__t3Xss = 1</script><img src="x" onerror="globalThis.__t3Xss = 2">',
535+
),
536+
]}
537+
/>,
538+
);
539+
540+
expect(markup).toContain("&lt;script&gt;globalThis.__t3Xss = 1&lt;/script&gt;");
541+
expect(markup).toContain(
542+
"&lt;img src=&quot;x&quot; onerror=&quot;globalThis.__t3Xss = 2&quot;&gt;",
543+
);
544+
expect(markup).not.toMatch(/<script(?:\s|>)/i);
545+
expect(markup).not.toMatch(/<img(?:\s|>)/i);
546+
});
547+
548+
it("continues to render sanitized raw HTML in assistant messages", async () => {
549+
const { MessagesTimeline } = await import("./MessagesTimeline");
550+
const markup = renderToStaticMarkup(
551+
<MessagesTimeline
552+
{...buildProps()}
553+
timelineEntries={[
554+
buildAssistantTimelineEntry("<details><summary>More</summary>Details</details>"),
555+
]}
556+
/>,
557+
);
558+
559+
expect(markup).toContain('data-markdown-details=""');
560+
expect(markup).toContain("More");
561+
expect(markup).not.toContain("&lt;details&gt;");
562+
});
563+
564+
it("sanitizes executable HTML while preserving supported assistant markup", async () => {
565+
const { MessagesTimeline } = await import("./MessagesTimeline");
566+
const markup = renderToStaticMarkup(
567+
<MessagesTimeline
568+
{...buildProps()}
569+
timelineEntries={[
570+
buildAssistantTimelineEntry(
571+
[
572+
'<details open onclick="globalThis.__t3Xss = 1">',
573+
"<summary>Safe details</summary>",
574+
"<script>globalThis.__t3Xss = 2</script>",
575+
'<img src="x" onerror="globalThis.__t3Xss = 3">',
576+
'<a href="javascript:globalThis.__t3Xss = 4">Unsafe link</a>',
577+
"</details>",
578+
].join(""),
579+
),
580+
]}
581+
/>,
582+
);
583+
584+
expect(markup).toContain('data-markdown-details=""');
585+
expect(markup).toContain("Safe details");
586+
expect(markup).not.toMatch(/<script(?:\s|>)/i);
587+
expect(markup).not.toContain("onclick=");
588+
expect(markup).not.toContain("onerror=");
589+
expect(markup).not.toContain("javascript:");
590+
expect(markup).not.toContain("globalThis.__t3Xss");
591+
});
592+
593+
it("renders inline terminal labels with the composer chip UI", async () => {
594+
const { MessagesTimeline } = await import("./MessagesTimeline");
468595
const markup = renderToStaticMarkup(
469596
<MessagesTimeline
470597
{...buildProps()}

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1692,6 +1692,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
16921692
skills={props.skills}
16931693
className="text-message-foreground"
16941694
lineBreaks
1695+
parseRawHtml={false}
16951696
/>
16961697
) : null}
16971698
{trailingWhitespace ? <span aria-hidden="true">{trailingWhitespace}</span> : null}
@@ -1714,6 +1715,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
17141715
skills={props.skills}
17151716
className="text-message-foreground"
17161717
lineBreaks
1718+
parseRawHtml={false}
17171719
/>
17181720
</div>
17191721
) : null
@@ -1802,6 +1804,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
18021804
skills={props.skills}
18031805
className="text-message-foreground"
18041806
lineBreaks
1807+
parseRawHtml={false}
18051808
/>,
18061809
);
18071810
} else if (inlinePrefix.length === 0) {
@@ -1827,6 +1830,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
18271830
skills={props.skills}
18281831
className="text-message-foreground"
18291832
lineBreaks
1833+
parseRawHtml={false}
18301834
/>
18311835
);
18321836
});

0 commit comments

Comments
 (0)