diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 871972ce44b..d932bb1d24f 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -334,6 +334,48 @@ ); return html; } + const MARKDOWN_ALLOWED_TAGS = new Set([ + "a", + "code", + "em", + "h1", + "h2", + "h3", + "h4", + "li", + "p", + "pre", + "strong", + "ul", + ]); + function escapeAttribute(value) { + return escapeHtml(value).replace(/`/g, "`"); + } + function sanitizeMarkdownAttrs(tag, attrs) { + if (tag === "a") { + const hrefMatch = + /\shref=(["'])(.*?)\1/i.exec(attrs) || + /\shref=([^\s>]+)/i.exec(attrs); + const href = hrefMatch ? (hrefMatch[2] || hrefMatch[1] || "").trim() : ""; + if (!/^(https?:\/\/|mailto:)/i.test(href)) return ""; + return ` href="${escapeAttribute(href)}" target="_blank" rel="noopener noreferrer"`; + } + if (tag === "pre" && /\sclass=(["'])hermes-kanban-md-code\1/i.test(attrs)) { + return ' class="hermes-kanban-md-code"'; + } + return ""; + } + function sanitizeMarkdownHtml(html) { + return String(html || "").replace( + /<\/?([a-zA-Z][A-Za-z0-9-]*)([^>]*)>/g, + (match, rawTag, attrs) => { + const tag = rawTag.toLowerCase(); + if (!MARKDOWN_ALLOWED_TAGS.has(tag)) return ""; + if (/^<\s*\//.test(match)) return ``; + return `<${tag}${sanitizeMarkdownAttrs(tag, attrs || "")}>`; + }, + ); + } function MarkdownBlock(props) { const enabled = props.enabled !== false; @@ -342,7 +384,7 @@ } return h("div", { className: "hermes-kanban-md", - dangerouslySetInnerHTML: { __html: renderMarkdown(props.source || "") }, + dangerouslySetInnerHTML: { __html: sanitizeMarkdownHtml(renderMarkdown(props.source || "")) }, }); } diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index e570c7627df..9833ea21069 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -247,6 +247,19 @@ def test_dashboard_initial_board_uses_backend_current_when_unpinned(): assert 'readSelectedBoard() || "default"' not in js +def test_dashboard_markdown_html_is_sanitized_before_render(): + """Markdown rendering must sanitize HTML before dangerouslySetInnerHTML.""" + + repo_root = Path(__file__).resolve().parents[2] + bundle = repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js" + js = bundle.read_text() + + assert "function sanitizeMarkdownHtml(html)" in js + assert "MARKDOWN_ALLOWED_TAGS" in js + assert "sanitizeMarkdownHtml(renderMarkdown(props.source || \"\"))" in js + assert "dangerouslySetInnerHTML: { __html: renderMarkdown(props.source || \"\") }" not in js + + # --------------------------------------------------------------------------- # GET /tasks/:id returns body + comments + events + links # ---------------------------------------------------------------------------