fix(ui): catch marked.js parse errors to prevent Control UI crash (#36445)

- Prevent Control UI session render crashes when `marked.parse()` encounters pathological recursive markdown by safely falling back to escaped `<pre>` output.
- Tighten markdown fallback regression coverage and keep changelog attribution in sync for this crash-hardening path.

Co-authored-by: Bin Deng <dengbin@romangic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Bin Deng
2026-03-06 03:46:49 +08:00
committed by GitHub
parent 6c0376145f
commit edc386e9a5
3 changed files with 51 additions and 6 deletions

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { marked } from "marked";
import { describe, expect, it, vi } from "vitest";
import { toSanitizedMarkdownHtml } from "./markdown.ts";
describe("toSanitizedMarkdownHtml", () => {
@@ -82,4 +83,36 @@ describe("toSanitizedMarkdownHtml", () => {
// Pipes from table delimiters must not appear as raw text
expect(html).not.toContain("|------|");
});
it("does not throw on deeply nested emphasis markers (#36213)", () => {
// Pathological patterns that can trigger catastrophic backtracking / recursion
const nested = "*".repeat(500) + "text" + "*".repeat(500);
expect(() => toSanitizedMarkdownHtml(nested)).not.toThrow();
const html = toSanitizedMarkdownHtml(nested);
expect(html).toContain("text");
});
it("does not throw on deeply nested brackets (#36213)", () => {
const nested = "[".repeat(200) + "link" + "]".repeat(200) + "(" + "x".repeat(200) + ")";
expect(() => toSanitizedMarkdownHtml(nested)).not.toThrow();
const html = toSanitizedMarkdownHtml(nested);
expect(html).toContain("link");
});
it("falls back to escaped plain text if marked.parse throws (#36213)", () => {
const parseSpy = vi.spyOn(marked, "parse").mockImplementation(() => {
throw new Error("forced parse failure");
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const input = `Fallback **probe** ${Date.now()}`;
try {
const html = toSanitizedMarkdownHtml(input);
expect(html).toContain('<pre class="code-block">');
expect(html).toContain("Fallback **probe**");
expect(warnSpy).toHaveBeenCalledOnce();
} finally {
parseSpy.mockRestore();
warnSpy.mockRestore();
}
});
});

View File

@@ -110,11 +110,20 @@ export function toSanitizedMarkdownHtml(markdown: string): string {
}
return sanitized;
}
const rendered = marked.parse(`${truncated.text}${suffix}`, {
renderer: htmlEscapeRenderer,
gfm: true,
breaks: true,
}) as string;
let rendered: string;
try {
rendered = marked.parse(`${truncated.text}${suffix}`, {
renderer: htmlEscapeRenderer,
gfm: true,
breaks: true,
}) as string;
} catch (err) {
// Fall back to escaped plain text when marked.parse() throws (e.g.
// infinite recursion on pathological markdown patterns — #36213).
console.warn("[markdown] marked.parse failed, falling back to plain text:", err);
const escaped = escapeHtml(`${truncated.text}${suffix}`);
rendered = `<pre class="code-block">${escaped}</pre>`;
}
const sanitized = DOMPurify.sanitize(rendered, sanitizeOptions);
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
setCachedMarkdown(input, sanitized);