From ec86b02b9efd7356523c63e738907c6148a7c138 Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Tue, 11 Aug 2026 20:03:08 -0300 Subject: [PATCH] fix(ui): render disallowed link schemes as plain text (#122311) Preserve readable Markdown labels while removing link wrappers for destinations the Control UI will not navigate. --- ui/src/components/markdown-parser.ts | 35 ++++++++++++++++++++-------- ui/src/components/markdown.test.ts | 25 +++++++++----------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/ui/src/components/markdown-parser.ts b/ui/src/components/markdown-parser.ts index fa70d22271cc..5d071d1b3dc6 100644 --- a/ui/src/components/markdown-parser.ts +++ b/ui/src/components/markdown-parser.ts @@ -20,6 +20,7 @@ import type { MarkdownRenderEnv } from "./markdown-render-options.ts"; import { escapeMarkdownHtml } from "./markdown-text.ts"; const INLINE_DATA_IMAGE_RE = /^data:image\/[a-z0-9.+-]+;base64,/i; +const DISALLOWED_LINK_SCHEME_RE = /^(?!(?:https?|mailto):)[a-z][a-z0-9+.-]*:/i; // 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. @@ -221,18 +222,32 @@ export function createMarkdownParser(): MarkdownIt { }, }); - // 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. + // Keep label tokens for invalid destinations; the rule below removes only the + // link wrapper so rejected Markdown stays readable without a false affordance. markdownParser.validateLink = () => true; + markdownParser.core.ruler.after("linkify", "disallowed-link-schemes", (state) => { + for (const blockToken of state.tokens) { + const children = blockToken.children; + if (blockToken.type !== "inline" || !children) { + continue; + } + let hideClose = false; + for (const token of children) { + if ( + token.type === "link_open" && + DISALLOWED_LINK_SCHEME_RE.test(token.attrGet("href") ?? "") + ) { + token.hidden = true; + hideClose = true; + } else if (token.type === "link_close" && hideClose) { + token.hidden = true; + hideClose = false; + } + } + } + }); + // 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 diff --git a/ui/src/components/markdown.test.ts b/ui/src/components/markdown.test.ts index 7d61733523e2..ed7be7cb0501 100644 --- a/ui/src/components/markdown.test.ts +++ b/ui/src/components/markdown.test.ts @@ -46,7 +46,7 @@ describe("toSanitizedMarkdownHtml", () => { ].join("\n"), ); expect(html).toBe( - '<script>alert(1)</script>\n\n

x

\n

ok

\n', + '<script>alert(1)</script>\n\n

x

\n

ok

\n', ); }); @@ -529,9 +529,16 @@ PY }); describe("security", () => { - it("blocks javascript: in links via DOMPurify", () => { - const html = toSanitizedMarkdownHtml("[click me](javascript:alert(1))"); - expect(html).toBe("

click me

\n"); + it.each([ + ["javascript:", "[JavaScript link](javascript:alert(1))", "JavaScript link"], + ["data:", "[Data link](data:text/html,test)", "Data link"], + ["vbscript:", "[VBScript link](vbscript:msgbox(1))", "VBScript link"], + ["file:", "[File link](file:///etc/passwd)", "File link"], + ])("renders disallowed %s links as plain text", (_scheme, markdown, label) => { + const fragment = htmlFragment(toSanitizedMarkdownHtml(markdown)); + + expect(fragment.querySelector("a")).toBeNull(); + expect(fragment.querySelector("p")?.textContent).toBe(label); }); it("shows alt text for javascript: images", () => { @@ -547,21 +554,11 @@ PY expect(html2).toBe("

Alt2

\n"); }); - it("renders non-image data: URIs as inert links (marked.js compat)", () => { - const html = toSanitizedMarkdownHtml("[x](data:text/html,)"); - expect(html).toBe("

x

\n"); - }); - it("does not auto-link bare file:// URIs", () => { const html = toSanitizedMarkdownHtml("Check file:///etc/passwd"); expect(html).toBe("

Check file:///etc/passwd

\n"); }); - it("strips href from explicit file:// links via DOMPurify", () => { - const html = toSanitizedMarkdownHtml("[click](file:///etc/passwd)"); - expect(html).toBe("

click

\n"); - }); - it("strips href from host-local absolute file paths", () => { const html = toSanitizedMarkdownHtml( "[report.docx](/Users/test/.openclaw/data/skills/output/report.docx)",