{"repo":"NousResearch/hermes-agent","public_id":"31934","display_label":"Kanban dashboard XSS via unsanitized markdown HTML","summary":"These items address a cross-site scripting vulnerability in the Kanban dashboard where markdown is rendered as HTML without sanitization. The work involves sanitizing HTML at the render sink to prevent injection attacks.","subsystem":"dashboard","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50392,"merged_at":"2026-06-21T20:10:17+00:00"},"resolution":{"state":"resolved","by":"pr-50392"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T20:10:17+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":31934,"pr_number":50392,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-21T20:10:17+00:00"}],"members":[{"kind":"issue","number":31934,"title":"[SECURITY] Kanban dashboard XSS via dangerouslySetInnerHTML without HTML sanitization","url":null,"state":"closed"},{"kind":"pr","number":39949,"title":"fix(security): sanitize Kanban markdown HTML","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f7dfe4bcc625825e9742d40daac6e3dbf3122fd0","number":0},{"kind":"pr","value":"#50392","number":50392},{"kind":"pr","value":"#39949","number":39949}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":50392,"title":"fix(security): sanitize Kanban dashboard markdown HTML at the render sink","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/kanban/dashboard/dist/index.js","tests/plugins/test_kanban_dashboard_plugin.py"],"fix_hunks":[{"file":"plugins/kanban/dashboard/dist/index.js","hunks":["@@ -334,6 +334,48 @@\n     );\n     return html;\n   }\n+  const MARKDOWN_ALLOWED_TAGS = new Set([\n+    \"a\",\n+    \"code\",\n+    \"em\",\n+    \"h1\",\n+    \"h2\",\n+    \"h3\",\n+    \"h4\",\n+    \"li\",\n+    \"p\",\n+    \"pre\",\n+    \"strong\",\n+    \"ul\",\n+  ]);\n+  function escapeAttribute(value) {\n+    return escapeHtml(value).replace(/`/g, \"&#96;\");\n+  }\n+  function sanitizeMarkdownAttrs(tag, attrs) {\n+    if (tag === \"a\") {\n+      const hrefMatch =\n+        /\\shref=([\"'])(.*?)\\1/i.exec(attrs) ||\n+        /\\shref=([^\\s>]+)/i.exec(attrs);\n+      const href = hrefMatch ? (hrefMatch[2] || hrefMatch[1] || \"\").trim() : \"\";\n+      if (!/^(https?:\\/\\/|mailto:)/i.test(href)) return \"\";\n+      return ` href=\"${escapeAttribute(href)}\" target=\"_blank\" rel=\"noopener noreferrer\"`;\n+    }\n+    if (tag === \"pre\" && /\\sclass=([\"'])hermes-kanban-md-code\\1/i.test(attrs)) {\n+      return ' class=\"hermes-kanban-md-code\"';\n+    }\n+    return \"\";\n+  }\n+  function sanitizeMarkdownHtml(html) {\n+    return String(html || \"\").replace(\n+      /<\\/?([a-zA-Z][A-Za-z0-9-]*)([^>]*)>/g,\n+      (match, rawTag, attrs) => {\n+        const tag = rawTag.toLowerCase();\n+        if (!MARKDOWN_ALLOWED_TAGS.has(tag)) return \"\";\n+        if (/^<\\s*\\//.test(match)) return `</${tag}>`;\n+        return `<${tag}${sanitizeMarkdownAttrs(tag, attrs || \"\")}>`;\n+      },\n+    );\n+  }\n \n   function MarkdownBlock(props) {\n     const enabled = props.enabled !== false;","@@ -342,7 +384,7 @@\n     }\n     return h(\"div\", {\n       className: \"hermes-kanban-md\",\n-      dangerouslySetInnerHTML: { __html: renderMarkdown(props.source || \"\") },\n+      dangerouslySetInnerHTML: { __html: sanitizeMarkdownHtml(renderMarkdown(props.source || \"\")) },\n     });\n   }\n "]},{"file":"tests/plugins/test_kanban_dashboard_plugin.py","hunks":["@@ -247,6 +247,19 @@ def test_dashboard_initial_board_uses_backend_current_when_unpinned():\n     assert 'readSelectedBoard() || \"default\"' not in js\n \n \n+def test_dashboard_markdown_html_is_sanitized_before_render():\n+    \"\"\"Markdown rendering must sanitize HTML before dangerouslySetInnerHTML.\"\"\"\n+\n+    repo_root = Path(__file__).resolve().parents[2]\n+    bundle = repo_root / \"plugins\" / \"kanban\" / \"dashboard\" / \"dist\" / \"index.js\"\n+    js = bundle.read_text()\n+\n+    assert \"function sanitizeMarkdownHtml(html)\" in js\n+    assert \"MARKDOWN_ALLOWED_TAGS\" in js\n+    assert \"sanitizeMarkdownHtml(renderMarkdown(props.source || \\\"\\\"))\" in js\n+    assert \"dangerouslySetInnerHTML: { __html: renderMarkdown(props.source || \\\"\\\") }\" not in js\n+\n+\n # ---------------------------------------------------------------------------\n # GET /tasks/:id returns body + comments + events + links\n # ---------------------------------------------------------------------------\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}