From 32ec404051a743e44b2b83d58bd31683049b00a4 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Mon, 24 Aug 2026 02:16:15 -0700 Subject: [PATCH] fix(markdown): guard deeply nested raw HTML in chat - Preserve normal raw HTML rendering and local image URL rewriting - Add regression tests for deeply nested HTML crash prevention --- .../parts/items/ItemMarkdownInner.test.tsx | 37 ++++++++ .../parts/items/ItemMarkdownInner.tsx | 85 +++++++++++++++++-- 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/thread/ChatPane/parts/items/ItemMarkdownInner.test.tsx b/src/renderer/components/thread/ChatPane/parts/items/ItemMarkdownInner.test.tsx index d13ce9b94..662f98e36 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/ItemMarkdownInner.test.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/ItemMarkdownInner.test.tsx @@ -126,6 +126,43 @@ describe("ItemMarkdownInner", () => { expect(container.querySelector("p")?.textContent).toBe("line 1\nline 2"); }); + it("does not crash on deeply nested raw HTML", () => { + const text = `${"
".repeat(5_000)}subagent result`; + + const { container } = render( + + + , + ); + + expect(container).toHaveTextContent("subagent result"); + }); + + it("keeps normal raw HTML elements rendered", () => { + const { container } = render( + + raw emphasis"} /> + , + ); + + expect(container.querySelector("em")).toHaveTextContent("raw emphasis"); + }); + + it("rewrites raw HTML image paths through the local image protocol", () => { + render( + + '} + /> + , + ); + + expect(screen.getByAltText("Raw image")).toHaveAttribute( + "src", + "poracode-local://local/C:/Users/sdsle/.poracode-smoke/raw-image.png", + ); + }); + it("caps markdown images so tall screenshots do not fill the chat", () => { const { container } = render( diff --git a/src/renderer/components/thread/ChatPane/parts/items/ItemMarkdownInner.tsx b/src/renderer/components/thread/ChatPane/parts/items/ItemMarkdownInner.tsx index f98150aa7..76b5c3c72 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/ItemMarkdownInner.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/ItemMarkdownInner.tsx @@ -56,11 +56,76 @@ type RehypePlugins = NonNullable["rehypePlugin function buildRehypePlugins(remoteLocalImageUrl?: (url: string) => string): RehypePlugins { return Object.entries(defaultRehypePlugins) .filter(([key]) => key !== "harden") - .flatMap(([key, plugin]) => - key === "sanitize" - ? [[rehypeLocalImageUrls, { remoteLocalImageUrl }], allowLocalImageProtocol(plugin)] - : [plugin], - ) as RehypePlugins; + .flatMap(([key, plugin]): RehypePlugins[number][] => { + // Streamdown checks the raw plugin by identity to preserve raw HTML + // nodes. Guard pathological fragments while keeping its plugin intact. + if (key === "raw") return [guardRawHtmlNesting, plugin]; + if (key === "sanitize") { + return [[rehypeLocalImageUrls, { remoteLocalImageUrl }], allowLocalImageProtocol(plugin)]; + } + return [plugin]; + }) as RehypePlugins; +} + +const MAX_RAW_HTML_NESTING = 1_000; +const RAW_HTML_TAG_RE = /|]*>|<\/?([A-Za-z][A-Za-z0-9:-]*)(?:\s[^<>]*?)?\/?>/g; +const RAW_HTML_VOID_ELEMENTS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +]); + +function guardRawHtmlNesting() { + return (tree: MarkdownHastNode): void => { + const pending = [tree]; + const state = { depth: 0 }; + while (pending.length > 0) { + const node = pending.pop(); + if (!node) continue; + if (node.type === "raw" && typeof node.value === "string") { + if (scanRawHtmlNesting(node.value, state)) { + replaceRawHtmlWithText(node); + state.depth = 0; + } + continue; + } + if (node.children) { + for (let index = node.children.length - 1; index >= 0; index--) { + const child = node.children[index]; + if (child) pending.push(child); + } + } + } + }; +} + +function scanRawHtmlNesting(value: string, state: { depth: number }): boolean { + RAW_HTML_TAG_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = RAW_HTML_TAG_RE.exec(value)) !== null) { + const tagName = match[1]?.toLowerCase(); + if (!tagName) continue; + if (value[match.index + 1] === "/") { + state.depth = Math.max(0, state.depth - 1); + continue; + } + if (value[match.index + match[0].length - 2] === "/") continue; + if (RAW_HTML_VOID_ELEMENTS.has(tagName)) continue; + state.depth += 1; + if (state.depth > MAX_RAW_HTML_NESTING) return true; + } + return false; } interface ItemMarkdownInnerProps { @@ -232,11 +297,21 @@ const transformMarkdownUrl: UrlTransform = (url, key, node) => : defaultUrlTransform(url, key, node); interface MarkdownHastNode { + type?: string; + value?: string; tagName?: string; properties?: Record; children?: MarkdownHastNode[]; } +function replaceRawHtmlWithText(node: MarkdownHastNode): void { + node.type = "text"; + node.value = node.value ?? ""; + delete node.tagName; + delete node.properties; + delete node.children; +} + function rehypeLocalImageUrls(options?: { remoteLocalImageUrl?: (url: string) => string }) { return (tree: MarkdownHastNode) => { rewriteLocalImageUrls(tree, options?.remoteLocalImageUrl);