mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
1982aaa351
commit
ec86b02b9e
@@ -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
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("toSanitizedMarkdownHtml", () => {
|
||||
].join("\n"),
|
||||
);
|
||||
expect(html).toBe(
|
||||
'<script>alert(1)</script>\n\n<p><a>x</a></p>\n<p><a href="https://example.com" rel="noreferrer noopener" target="_blank">ok</a></p>\n',
|
||||
'<script>alert(1)</script>\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)",
|
||||
|
||||
Reference in New Issue
Block a user