Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pick<ServerProviderSkill, "name" | "displayName">> = [];
Expand Down Expand Up @@ -1256,6 +1258,7 @@ function ChatMarkdown({
skills = EMPTY_MARKDOWN_SKILLS,
className,
lineBreaks = false,
parseRawHtml = true,
}: ChatMarkdownProps) {
const { resolvedTheme } = useTheme();
const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
Expand Down Expand Up @@ -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 (
<div
className={cn(
Expand All @@ -1566,7 +1572,8 @@ function ChatMarkdown({
remarkPlugins={
lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS
}
rehypePlugins={CHAT_MARKDOWN_REHYPE_PLUGINS}
rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined}
Comment thread
cursor[bot] marked this conversation as resolved.
skipHtml={false}
components={markdownComponents}
urlTransform={markdownUrlTransform}
>
Expand Down
126 changes: 126 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
buildUserTimelineEntry(
[
'Without reading a file, do you have <global-agent-instructions scope="workspace">',
'Before <nested data-value="a&b">inside</nested> after',
"</global-agent-instructions> in your context?",
"Comparison: 2 < 3 and 5 > 4.",
].join("\n"),
),
]}
/>,
);

expect(markup).toContain("&lt;global-agent-instructions scope=&quot;workspace&quot;&gt;");
expect(markup).toContain(
"Before &lt;nested data-value=&quot;a&amp;b&quot;&gt;inside&lt;/nested&gt; after",
);
expect(markup).toContain("&lt;/global-agent-instructions&gt; in your context?");
expect(markup).toContain("Comparison: 2 &lt; 3 and 5 &gt; 4.");
});

it("preserves XML-like source inside user code spans and fences", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
buildUserTimelineEntry(
[
'Inline `<tag attr="x">`',
"",
"```xml",
'<root><child enabled="true" /></root>',
"```",
].join("\n"),
),
]}
/>,
);

expect(markup).toContain("<code>&lt;tag attr=&quot;x&quot;&gt;</code>");
expect(markup).toContain("&lt;root&gt;&lt;child enabled=&quot;true&quot; /&gt;&lt;/root&gt;");
});

it("renders unsafe user HTML as inert source text", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
buildUserTimelineEntry(
'<script>globalThis.__t3Xss = 1</script><img src="x" onerror="globalThis.__t3Xss = 2">',
),
]}
/>,
);

expect(markup).toContain("&lt;script&gt;globalThis.__t3Xss = 1&lt;/script&gt;");
expect(markup).toContain(
"&lt;img src=&quot;x&quot; onerror=&quot;globalThis.__t3Xss = 2&quot;&gt;",
);
expect(markup).not.toMatch(/<script(?:\s|>)/i);
expect(markup).not.toMatch(/<img(?:\s|>)/i);
});

it("continues to render sanitized raw HTML in assistant messages", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
buildAssistantTimelineEntry("<details><summary>More</summary>Details</details>"),
]}
/>,
);

expect(markup).toContain('data-markdown-details=""');
expect(markup).toContain("More");
expect(markup).not.toContain("&lt;details&gt;");
});

it("sanitizes executable HTML while preserving supported assistant markup", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
buildAssistantTimelineEntry(
[
'<details open onclick="globalThis.__t3Xss = 1">',
"<summary>Safe details</summary>",
"<script>globalThis.__t3Xss = 2</script>",
'<img src="x" onerror="globalThis.__t3Xss = 3">',
'<a href="javascript:globalThis.__t3Xss = 4">Unsafe link</a>',
"</details>",
].join(""),
),
]}
/>,
);

expect(markup).toContain('data-markdown-details=""');
expect(markup).toContain("Safe details");
expect(markup).not.toMatch(/<script(?:\s|>)/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(
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1553,6 +1553,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
skills={props.skills}
className="text-foreground"
lineBreaks
parseRawHtml={false}
/>
) : null}
{trailingWhitespace ? <span aria-hidden="true">{trailingWhitespace}</span> : null}
Expand All @@ -1575,6 +1576,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
skills={props.skills}
className="text-foreground"
lineBreaks
parseRawHtml={false}
/>
</div>
) : null
Expand Down Expand Up @@ -1663,6 +1665,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
skills={props.skills}
className="text-foreground"
lineBreaks
parseRawHtml={false}
/>,
);
} else if (inlinePrefix.length === 0) {
Expand All @@ -1688,6 +1691,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
skills={props.skills}
className="text-foreground"
lineBreaks
parseRawHtml={false}
/>
);
});
Expand Down
Loading