diff --git a/CHANGELOG.md b/CHANGELOG.md
index 143645c2c8a..32543f60d20 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@ Docs: https://docs.openclaw.ai
## Unreleased
+- fix(ui): replace marked.js with markdown-it to fix ReDoS UI freeze (#46707) thanks @zhangfnf
+
### Changes
- Telegram/forum topics: surface human topic names in agent context, prompt metadata, and plugin hook metadata by learning names from Telegram forum service messages. (#65973) Thanks @ptahdunbar.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f105f25933c..7dc9d79af81 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1297,10 +1297,19 @@ importers:
lit:
specifier: ^3.3.2
version: 3.3.2
+ markdown-it:
+ specifier: ^14.1.1
+ version: 14.1.1
+ markdown-it-task-lists:
+ specifier: ^2.1.1
+ version: 2.1.1
marked:
specifier: ^18.0.0
version: 18.0.0
devDependencies:
+ '@types/markdown-it':
+ specifier: ^14.1.2
+ version: 14.1.2
'@vitest/browser-playwright':
specifier: 4.1.4
version: 4.1.4(playwright@1.59.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)
@@ -6055,6 +6064,9 @@ packages:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
+ markdown-it-task-lists@2.1.1:
+ resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
+
markdown-it@14.1.1:
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
hasBin: true
@@ -13273,6 +13285,8 @@ snapshots:
dependencies:
semver: 7.7.4
+ markdown-it-task-lists@2.1.1: {}
+
markdown-it@14.1.1:
dependencies:
argparse: 2.0.1
diff --git a/ui/package.json b/ui/package.json
index f548ae10d6e..d9ae1d0a1e0 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -13,9 +13,12 @@
"@noble/ed25519": "3.0.1",
"dompurify": "^3.3.3",
"lit": "^3.3.2",
+ "markdown-it": "^14.1.1",
+ "markdown-it-task-lists": "^2.1.1",
"marked": "^18.0.0"
},
"devDependencies": {
+ "@types/markdown-it": "^14.1.2",
"@vitest/browser-playwright": "4.1.4",
"jsdom": "^29.0.2",
"playwright": "^1.59.1",
diff --git a/ui/src/markdown-it-task-lists.d.ts b/ui/src/markdown-it-task-lists.d.ts
new file mode 100644
index 00000000000..80f53dddadc
--- /dev/null
+++ b/ui/src/markdown-it-task-lists.d.ts
@@ -0,0 +1,10 @@
+declare module "markdown-it-task-lists" {
+ import type MarkdownIt from "markdown-it";
+ interface TaskListsOptions {
+ enabled?: boolean;
+ label?: boolean;
+ labelAfter?: boolean;
+ }
+ const plugin: (md: MarkdownIt, options?: TaskListsOptions) => void;
+ export default plugin;
+}
diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css
index ca2227658db..b06b9d59de0 100644
--- a/ui/src/styles/chat/text.css
+++ b/ui/src/styles/chat/text.css
@@ -41,6 +41,20 @@
margin-top: 0.25em;
}
+/* Hide default marker only for unordered task lists; ordered lists keep numbers */
+.chat-text :where(ul > .task-list-item),
+.sidebar-markdown :where(ul > .task-list-item),
+.chat-thinking :where(ul > .task-list-item) {
+ list-style: none;
+}
+
+.chat-text :where(.task-list-item-checkbox),
+.sidebar-markdown :where(.task-list-item-checkbox),
+.chat-thinking :where(.task-list-item-checkbox) {
+ margin-right: 0.4em;
+ vertical-align: middle;
+}
+
.chat-text :where(a) {
color: var(--accent);
text-decoration: underline;
diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts
index e27faf8fbaa..831f7b4f7f8 100644
--- a/ui/src/ui/markdown.test.ts
+++ b/ui/src/ui/markdown.test.ts
@@ -1,8 +1,8 @@
-import { marked } from "marked";
import { describe, expect, it, vi } from "vitest";
-import { toSanitizedMarkdownHtml } from "./markdown.ts";
+import { md, toSanitizedMarkdownHtml } from "./markdown.ts";
describe("toSanitizedMarkdownHtml", () => {
+ // ── Original tests from before markdown-it migration ──
it("renders basic markdown", () => {
const html = toSanitizedMarkdownHtml("Hello **world**");
expect(html).toContain("world");
@@ -146,9 +146,9 @@ describe("toSanitizedMarkdownHtml", () => {
expect(second).toBe(first);
});
- 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");
+ it("falls back to escaped plain text if md.render throws (#36213)", () => {
+ const renderSpy = vi.spyOn(md, "render").mockImplementation(() => {
+ throw new Error("forced render failure");
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const input = `Fallback **probe** ${Date.now()}`;
@@ -158,26 +158,484 @@ describe("toSanitizedMarkdownHtml", () => {
expect(html).toContain("Fallback **probe**");
expect(warnSpy).toHaveBeenCalledOnce();
} finally {
- parseSpy.mockRestore();
+ renderSpy.mockRestore();
warnSpy.mockRestore();
}
});
- it("keeps adjacent trailing CJK text outside bare auto-links", () => {
- const html = toSanitizedMarkdownHtml("https://example.com重新解读");
- expect(html).toContain('https://example.com重新解读");
+ // ── Additional tests for markdown-it migration ──
+ describe("www autolinks", () => {
+ it("links www.example.com", () => {
+ const html = toSanitizedMarkdownHtml("Visit www.example.com today");
+ expect(html).toContain('");
+ });
+
+ it("links www.example.com with path, query, and fragment", () => {
+ const html = toSanitizedMarkdownHtml("See www.example.com/path?a=1#section");
+ expect(html).toContain(' {
+ const html = toSanitizedMarkdownHtml("Visit www.example.com:8080/foo");
+ expect(html).toContain(' {
+ const html = toSanitizedMarkdownHtml("Visit www.localhost:3000/path for dev");
+ expect(html).toContain(' {
+ // markdown-it linkify converts IDN to punycode; marked.js percent-encodes.
+ // Both are valid; we just verify the link is created.
+ const html1 = toSanitizedMarkdownHtml("Visit www.münich.de");
+ expect(html1).toContain("www.münich.de");
+
+ const html2 = toSanitizedMarkdownHtml("Visit www.café.example");
+ expect(html2).toContain("www.café.example");
+ });
+
+ it("links www.foo_bar.example.com with underscores", () => {
+ const html = toSanitizedMarkdownHtml("Visit www.foo_bar.example.com");
+ expect(html).toContain(' {
+ const html1 = toSanitizedMarkdownHtml("Check www.example.com/help.");
+ expect(html1).toContain('href="http://www.example.com/help"');
+ expect(html1).not.toContain('href="http://www.example.com/help."');
+
+ const html2 = toSanitizedMarkdownHtml("See www.example.com!");
+ expect(html2).toContain('href="http://www.example.com"');
+ expect(html2).not.toContain('href="http://www.example.com!"');
+ });
+
+ it("strips entity-like suffixes per GFM spec", () => {
+ // &hl; looks like an entity reference, so strip it
+ const html1 = toSanitizedMarkdownHtml("www.google.com/search?q=commonmark&hl;");
+ expect(html1).toContain('href="http://www.google.com/search?q=commonmark"');
+ expect(html1).toContain("&hl;"); // Entity shown outside link
+
+ // & is also entity-like
+ const html2 = toSanitizedMarkdownHtml("www.example.com/path&");
+ expect(html2).toContain('href="http://www.example.com/path"');
+ });
+
+ it("handles quotes with balance checking", () => {
+ // Quoted URL — trailing unbalanced " is stripped
+ const html1 = toSanitizedMarkdownHtml('"www.example.com"');
+ expect(html1).toContain('href="http://www.example.com"');
+ expect(html1).not.toContain('href="http://www.example.com%22"');
+
+ // Balanced quotes inside path — preserved
+ const html2 = toSanitizedMarkdownHtml('www.example.com/path"with"quotes');
+ expect(html2).toContain('www.example.com/path"with"quotes');
+
+ // Trailing unbalanced " — stripped
+ const html3 = toSanitizedMarkdownHtml('www.example.com/path"');
+ expect(html3).toContain('href="http://www.example.com/path"');
+ expect(html3).not.toContain('path%22"');
+ });
+
+ it("does NOT link www. domains starting with non-ASCII", () => {
+ const html1 = toSanitizedMarkdownHtml("Visit www.ünich.de");
+ expect(html1).not.toContain(" {
+ const html = toSanitizedMarkdownHtml("(see www.example.com/foo(bar))");
+ expect(html).toContain('href="http://www.example.com/foo(bar)"');
+ });
+
+ it("stops at < character", () => {
+ // Stops at < character
+ const html1 = toSanitizedMarkdownHtml("Visit www.example.com/path
');
+ expect(warnSpy).toHaveBeenCalledOnce();
+ } finally {
+ renderSpy.mockRestore();
+ warnSpy.mockRestore();
+ }
+ });
});
});
diff --git a/ui/src/ui/markdown.ts b/ui/src/ui/markdown.ts
index dc03b61f225..da34ab16815 100644
--- a/ui/src/ui/markdown.ts
+++ b/ui/src/ui/markdown.ts
@@ -1,5 +1,6 @@
import DOMPurify from "dompurify";
-import { marked } from "marked";
+import MarkdownIt from "markdown-it";
+import markdownItTaskLists from "markdown-it-task-lists";
import { truncateText } from "./format.ts";
import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts";
@@ -20,10 +21,12 @@ const allowedTags = [
"h4",
"hr",
"i",
+ "input",
"li",
"ol",
"p",
"pre",
+ "s",
"span",
"strong",
"summary",
@@ -38,7 +41,9 @@ const allowedTags = [
];
const allowedAttrs = [
+ "checked",
"class",
+ "disabled",
"href",
"rel",
"target",
@@ -64,7 +69,13 @@ const MARKDOWN_CACHE_MAX_CHARS = 50_000;
const INLINE_DATA_IMAGE_RE = /^data:image\/[a-z0-9.+-]+;base64,/i;
const markdownCache = new Map();
const TAIL_LINK_BLUR_CLASS = "chat-link-tail-blur";
-const TRAILING_CJK_TAIL_RE = /([\u4E00-\u9FFF\u3000-\u303F\uFF01-\uFF5E\s]+)$/;
+
+// CJK character ranges for URL boundary detection (RFC 3986: CJK is not valid in raw URLs).
+// CJK Unified Ideographs, CJK Symbols/Punctuation, Fullwidth Forms, Hiragana, Katakana,
+// Hangul Syllables, and CJK Compatibility Ideographs.
+// biome-ignore lint: readability — regex charset is inherently dense
+const CJK_RE =
+ /[\u2E80-\u2FFF\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF01-\uFF60]/;
function getCachedMarkdown(key: string): string | null {
const cached = markdownCache.get(key);
@@ -123,50 +134,346 @@ function installHooks() {
});
}
-// Extension to prevent auto-linking algorithms from swallowing adjacent CJK characters.
-const cjkAutoLinkExtension = {
- name: "url",
- level: "inline",
- // Indicate where an auto-link might start
- start(src: string) {
- const match = src.match(/https?:\/\//i);
- return match ? match.index! : -1;
- },
- tokenizer(src: string) {
- // GFM standard regex for auto-links
- const rule = /^https?:\/\/[^\s<]+[^<.,:;"')\]\s]/i;
- const match = rule.exec(src);
- if (match) {
- let urlText = match[0];
+// ── markdown-it instance with custom renderers ──
- // Stop before any CJK character or typical punctuation following CJK
- // This stops link boundaries from bleeding into mixed-language paragraphs.
- const cjkMatch = urlText.match(TRAILING_CJK_TAIL_RE);
- if (cjkMatch) {
- urlText = urlText.substring(0, urlText.length - cjkMatch[1].length);
- }
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
- return {
- type: "link",
- raw: urlText,
- text: urlText,
- href: urlText,
- tokens: [
- {
- type: "text",
- raw: urlText,
- text: urlText,
- },
- ],
- };
+function normalizeMarkdownImageLabel(text?: string | null): string {
+ const trimmed = text?.trim();
+ return trimmed ? trimmed : "image";
+}
+
+export const md = new MarkdownIt({
+ html: true, // Enable HTML recognition so html_block/html_inline overrides can escape it
+ breaks: true,
+ linkify: true,
+});
+
+// Enable GFM strikethrough (~~text~~) to match original marked.js behavior.
+// markdown-it uses tags; we added "s" to allowedTags for DOMPurify.
+md.enable("strikethrough");
+
+// Disable fuzzy link detection to prevent bare filenames like "README.md"
+// from being auto-linked as "http://README.md". URLs with explicit protocol
+// (https://...) and emails are still linkified.
+//
+// Alternative considered: extensions/matrix/src/matrix/format.ts uses fuzzyLink
+// with a file-extension blocklist to filter false positives at render time.
+// We chose the www-only approach instead because:
+// 1. Matches original marked.js GFM behavior exactly (bare domains were never linked)
+// 2. No blocklist to maintain — new TLDs like .ai, .io, .dev would need constant updates
+// 3. Predictable behavior — users can always use explicit https:// for any URL
+md.linkify.set({ fuzzyLink: false });
+
+// Re-enable www. prefix detection per GFM spec: bare URLs without protocol
+// must start with "www." to be auto-linked. This avoids false positives on
+// filenames while preserving expected behavior for "www.example.com".
+// GFM spec: valid domain = alphanumeric/underscore/hyphen segments separated
+// by periods, at least one period, no underscores in last two segments.
+md.linkify.add("www", {
+ validate(text, pos) {
+ const tail = text.slice(pos);
+ // Match: . followed by domain and optional path, matching marked.js behavior.
+ // Stops at whitespace, < (HTML tag boundary), or CJK characters (RFC 3986:
+ // raw CJK is not valid in URLs; percent-encoded CJK like %E4%BD%A0 is fine).
+ const match = tail.match(
+ /^\.(?:[a-zA-Z0-9-]+\.?)+[^\s<\u2E80-\u2FFF\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF01-\uFF60]*/,
+ );
+ if (!match) {
+ return 0;
}
- return undefined;
+ let len = match[0].length;
+
+ // Strip trailing punctuation per GFM extended autolink spec.
+ // GFM says: ?, !, ., ,, :, *, _, ~ are not part of the autolink if trailing.
+
+ // Balance checking config: closeChar -> openChar mapping.
+ // Strip trailing close chars only when unbalanced (more closes than opens).
+ // For self-matching pairs like "", open === close (strip if odd count).
+ const balancePairs: Record = {
+ ")": "(",
+ "]": "[",
+ "}": "{",
+ '"': '"',
+ "'": "'",
+ };
+
+ // Pre-count balanced pairs to avoid O(n²) rescans.
+ // balance[closeChar] = count(open) - count(close), negative means unbalanced
+ const balance: Record = {};
+ for (const [close, open] of Object.entries(balancePairs)) {
+ balance[close] = 0;
+ for (let i = 0; i < len; i++) {
+ const c = tail[i];
+ if (open === close) {
+ // Self-matching pair (e.g., "") — toggle between 0 and 1
+ if (c === open) {
+ balance[close] = balance[close] === 0 ? 1 : 0;
+ }
+ } else {
+ // Distinct open/close (e.g., ())
+ if (c === open) {
+ balance[close]++;
+ } else if (c === close) {
+ balance[close]--;
+ }
+ }
+ }
+ }
+
+ while (len > 0) {
+ const ch = tail[len - 1];
+ // GFM trailing punctuation: ?, !, ., ,, :, *, _, ~ stripped unconditionally.
+ // Semicolon is handled specially below (entity reference rule).
+ if (/[?!.,:*_~]/.test(ch)) {
+ len--;
+ continue;
+ }
+ // GFM entity reference rule: strip trailing &entity; sequences.
+ // Only strip ; when preceded by &+ (e.g., & < &hl;).
+ if (ch === ";") {
+ // Backward scan to find & (O(n) total, avoids string allocation)
+ let j = len - 2;
+ while (j >= 0 && /[a-zA-Z0-9]/.test(tail[j])) {
+ j--;
+ }
+ // j < len - 2 ensures at least one alphanumeric between & and ;
+ if (j >= 0 && tail[j] === "&" && j < len - 2) {
+ len = j;
+ continue;
+ }
+ // Not an entity reference, stop stripping
+ break;
+ }
+ // Handle balanced pairs — only strip close char if unbalanced.
+ const open = balancePairs[ch];
+ if (open !== undefined) {
+ if (open === ch) {
+ // Self-matching: strip if odd count (unbalanced)
+ if (balance[ch] !== 0) {
+ balance[ch] = 0;
+ len--;
+ continue;
+ }
+ } else {
+ // Distinct pair: strip if more closes than opens
+ if (balance[ch] < 0) {
+ balance[ch]++;
+ len--;
+ continue;
+ }
+ }
+ }
+ break;
+ }
+ return len;
+
},
+ normalize(match) {
+ match.url = "http://" + match.url;
+ },
+});
+
+// Override default link validator to allow all URLs through to renderers.
+// marked.js does not validate URLs at all — it generates /
tags for
+// everything and relies on DOMPurify to strip dangerous schemes.
+//
+// We match this behavior exactly:
+// - All URLs pass validation, including javascript:, vbscript:, file:, data:
+// - Images: renderer.rules.image shows alt text for non-data-image URLs
+// - Links: DOMPurify strips dangerous href schemes, leaving safe anchor text
+// - Blocking at validateLink would skip token generation entirely, causing raw
+// markdown source to appear instead of graceful fallbacks.
+md.validateLink = () => true;
+
+// Trim trailing CJK characters from auto-linked URLs (RFC 3986: raw CJK is
+// not valid in URLs). markdown-it's built-in linkify for https:// URLs may
+// swallow adjacent CJK text into the URL. This core rule runs after linkify
+// and splits the CJK suffix back into a plain text token.
+md.core.ruler.after("linkify", "linkify-cjk-trim", (state) => {
+ for (const blockToken of state.tokens) {
+ if (blockToken.type !== "inline" || !blockToken.children) {
+ continue;
+ }
+ const children = blockToken.children;
+ for (let i = children.length - 1; i >= 0; i--) {
+ const token = children[i];
+ if (token.type !== "link_open") {
+ continue;
+ }
+ // Only trim linkify-generated autolinks, not explicit markdown links
+ // like [OpenClaw中文](https://docs.openclaw.ai) where CJK in display
+ // text is intentional and href must not be rewritten.
+ if (token.markup !== "linkify") {
+ continue;
+ }
+ // Use the display text to find CJK boundary (href may be percent-encoded)
+ const textToken = children[i + 1];
+ if (!textToken || textToken.type !== "text") {
+ continue;
+ }
+ const displayText = textToken.content;
+ // Scan backward to find trailing CJK suffix only.
+ // Middle CJK must be preserved (e.g. https://example.com/你/test stays intact);
+ // only strip a contiguous CJK tail adjacent to non-URL text.
+ let cjkIdx = displayText.length;
+ while (cjkIdx > 0 && CJK_RE.test(displayText[cjkIdx - 1])) {
+ cjkIdx--;
+ }
+ if (cjkIdx <= 0 || cjkIdx === displayText.length) {
+ continue;
+ }
+ // Split: URL part and CJK tail from display text
+ const trimmedDisplay = displayText.slice(0, cjkIdx);
+ const cjkTail = displayText.slice(cjkIdx);
+ // Rebuild href by preserving the scheme prefix that linkify added but
+ // display text omits (e.g. "mailto:" for emails, "http://" for www links).
+ const href = token.attrGet("href") ?? "";
+ const prefixLen = href.indexOf(displayText);
+ const hrefPrefix = prefixLen > 0 ? href.slice(0, prefixLen) : "";
+ token.attrSet("href", hrefPrefix + trimmedDisplay);
+ textToken.content = trimmedDisplay;
+ // Find link_close and insert CJK text after it
+ for (let j = i + 1; j < children.length; j++) {
+ if (children[j].type === "link_close") {
+ const tailToken = new state.Token("text", "", 0);
+ tailToken.content = cjkTail;
+ children.splice(j + 1, 0, tailToken);
+ break;
+ }
+ }
+ }
+ }
+});
+
+// Enable GFM task list checkboxes (- [x] / - [ ]).
+// enabled: false keeps checkboxes read-only (disabled="") — task lists in
+// chat messages are display-only, not interactive forms.
+// label: false avoids wrapping item text in