Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `${"<div>".repeat(5_000)}subagent result`;

const { container } = render(
<AppProvider>
<ItemMarkdownInner text={text} />
</AppProvider>,
);

expect(container).toHaveTextContent("subagent result");
});

it("keeps normal raw HTML elements rendered", () => {
const { container } = render(
<AppProvider>
<ItemMarkdownInner text={"<em>raw emphasis</em>"} />
</AppProvider>,
);

expect(container.querySelector("em")).toHaveTextContent("raw emphasis");
});

it("rewrites raw HTML image paths through the local image protocol", () => {
render(
<AppProvider>
<ItemMarkdownInner
text={'<img src="C:/Users/sdsle/.poracode-smoke/raw-image.png" alt="Raw image" />'}
/>
</AppProvider>,
);

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(
<AppProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,76 @@ type RehypePlugins = NonNullable<ComponentProps<typeof Streamdown>["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 {
Expand Down Expand Up @@ -232,11 +297,21 @@ const transformMarkdownUrl: UrlTransform = (url, key, node) =>
: defaultUrlTransform(url, key, node);

interface MarkdownHastNode {
type?: string;
value?: string;
tagName?: string;
properties?: Record<string, unknown>;
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);
Expand Down