# NousResearch/hermes-agent · Kanban dashboard XSS via unsanitized markdown HTML

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #50392. Verify it addresses your case.
- **Recommended:** read PR #50392 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-50392`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #31934: \[SECURITY\] Kanban dashboard XSS via dangerouslySetInnerHTML without HTML sanitization

## Prior & competing PRs

- **#50392: fix(security): sanitize Kanban dashboard markdown HTML at the render sink**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `plugins/kanban/dashboard/dist/index.js`, `tests/plugins/test_kanban_dashboard_plugin.py`
  - fix hunks:
  - `plugins/kanban/dashboard/dist/index.js`

    ```diff
    @@ -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, "&#96;");
    +  }
    +  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 `</${tag}>`;
    +        return `<${tag}${sanitizeMarkdownAttrs(tag, attrs || "")}>`;
    +      },
    +    );
    +  }
     
       function MarkdownBlock(props) {
         const enabled = props.enabled !== false;
    ```

    ```diff
    @@ -342,7 +384,7 @@
         }
         return h("div", {
           className: "hermes-kanban-md",
    -      dangerouslySetInnerHTML: { __html: renderMarkdown(props.source || "") },
    +      dangerouslySetInnerHTML: { __html: sanitizeMarkdownHtml(renderMarkdown(props.source || "")) },
         });
       }
     
    ```
  - `tests/plugins/test_kanban_dashboard_plugin.py`

    ```diff
    @@ -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
     # ---------------------------------------------------------------------------
    ```
- **#39949: fix(security): sanitize Kanban markdown HTML**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `f7dfe4bcc625825e9742d40daac6e3dbf3122fd0`, `#50392`, `#39949`

## Fix status

- Merged to main in PR #50392 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-21`.
- Subsystem: `dashboard`
