web-ui: sanitize markdown HTML with DOMPurify to fix XSS

Both mdNode implementations (agent UI app.js, dashboard common.js)
assigned marked.parse() output straight to innerHTML with no
sanitizer. marked v5+ dropped its built-in sanitize option, and there
was no DOMPurify anywhere in frontend/, so markdown containing raw
HTML/script tags rendered live in the browser.

Both sinks receive untrusted input in practice: the agent UI's mdNode
renders recv tool_result bodies, assistant prose, and send/ask/answer
payloads sourced from peer agents and matrix-relayed messages (the
documented prompt-injection adversary); the dashboard's mdNode renders
agent-authored .md files served verbatim by GET /api/state-file
(the endpoint validates path, not content). Since the per-agent UI and
dashboard are same-origin behind the gateway with operator-authority
endpoints (approve/spawn/rebuild/destroy/answer-question), injected
script would run with the operator's session.

Fix: DOMPurify.sanitize() the marked.parse() output at both sinks
before assigning to innerHTML. Added dompurify as a dependency to
both the agent and dashboard npm workspaces, recomputed npmDepsHash
in nix/frontend.nix for the updated lockfile. Also corrected
docs/web-ui/shape.md, which claimed the markdown-rendering path was
XSS-safe by construction the same way the text-node-based linkify
path is — it isn't; it's safe because it's sanitized.

CSP hardening for the dashboard (no unsafe-inline) is a separate,
larger backend change (response headers in hive-c0re) and is left as
a fast-follow rather than folded into this fix.
This commit is contained in:
iris 2026-07-10 02:46:51 +02:00 committed by mara
commit ccc5e631e2
7 changed files with 50 additions and 12 deletions

View file

@ -27,10 +27,14 @@
registers a kind→renderer map; unknown kinds fall through to
a JSON-dump note row. Bare `http(s)://` URLs in row text are
turned into clickable new-tab links by `linkify` (text-node
based, no `innerHTML` — XSS-safe); markdown bodies get the
same treatment via `marked`'s autolink (npm dep, replacing the
vendored UMD bundle), with the rendered `<a>`s rewritten to
`target="_blank"`.
based, no `innerHTML` — XSS-safe); markdown bodies go through
`marked` (npm dep, replacing the vendored UMD bundle) and then
`DOMPurify.sanitize()` before hitting `innerHTML` — untrusted row
text (peer-agent / matrix-relayed message bodies, agent-authored
state files) can carry arbitrary HTML/script via markdown, so the
markdown path is sanitized rather than XSS-safe by construction the
way the text-node `linkify` path is. Rendered `<a>`s are rewritten
to `target="_blank"`.
- `GET /api/state` → JSON snapshot the JS app renders into the
DOM. Includes a top-level `seq` (the dashboard event channel's
high-water mark at the moment the snapshot was assembled);
@ -177,8 +181,11 @@ text get wrapped in `<a target="_blank" rel="noopener noreferrer">`
inside a fresh text node, so the autolinker never touches
`innerHTML` and untrusted row content can't smuggle markup. The
trailing-punctuation strip keeps `.,;:` outside the link surface.
Markdown bodies go through `marked` separately and get the same
target rewrite.
Markdown bodies go through `marked` separately, then
`DOMPurify.sanitize()` on the resulting HTML before it's assigned to
`innerHTML` (see `mdNode` in `app.js` / `common.js`) — this path is
sanitized, not text-node-safe like `linkify`, since markdown can
carry raw HTML. Rendered `<a>`s get the same target rewrite.
The JS app handles all `form[data-async]` submissions via a delegated
listener: read `data-confirm`, swap the button to a spinner, POST