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.
This commit is contained in:
Vyctor H. Brzezowski
2026-08-11 20:03:08 -03:00
committed by GitHub
parent 1982aaa351
commit ec86b02b9e
2 changed files with 36 additions and 24 deletions
+25 -10
View File
@@ -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 <a>/<img> 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
+11 -14
View File
@@ -46,7 +46,7 @@ describe("toSanitizedMarkdownHtml", () => {
].join("\n"),
);
expect(html).toBe(
'&lt;script&gt;alert(1)&lt;/script&gt;\n\n<p><a>x</a></p>\n<p><a href="https://example.com" rel="noreferrer noopener" target="_blank">ok</a></p>\n',
'&lt;script&gt;alert(1)&lt;/script&gt;\n\n<p>x</p>\n<p><a href="https://example.com" rel="noreferrer noopener" target="_blank">ok</a></p>\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("<p><a>click me</a></p>\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("<p>Alt2</p>\n");
});
it("renders non-image data: URIs as inert links (marked.js compat)", () => {
const html = toSanitizedMarkdownHtml("[x](data:text/html,<script>alert(1)</script>)");
expect(html).toBe("<p><a>x</a></p>\n");
});
it("does not auto-link bare file:// URIs", () => {
const html = toSanitizedMarkdownHtml("Check file:///etc/passwd");
expect(html).toBe("<p>Check file:///etc/passwd</p>\n");
});
it("strips href from explicit file:// links via DOMPurify", () => {
const html = toSanitizedMarkdownHtml("[click](file:///etc/passwd)");
expect(html).toBe("<p><a>click</a></p>\n");
});
it("strips href from host-local absolute file paths", () => {
const html = toSanitizedMarkdownHtml(
"[report.docx](/Users/test/.openclaw/data/skills/output/report.docx)",