fix(dashboard): escape instance data at innerHTML sinks (XSS)
Problem
The static dashboard renders instance-derived free text (usernames, project names, team/workshop labels, gap text) into the DOM via innerHTML. Escaping was applied by convention at each call site. A single missed escapeHtml would make a project named <img src=x onerror=alert(1)> or a crafted username a stored cross-site scripting (XSS) sink that executes when a customer opens the dashboard.
XSS sinks reviewed
Audited all 45 innerHTML / insertAdjacentHTML / showTooltip sites across internal/renderer/static/src/*.js. The user/project identity sinks that reach innerHTML are:
06-profiles.jsprofiles table name cell + title attribute07-influence.jsinfluence ranking table cell, ego-network node and project tooltips05-groups.jsenablement group member link08-detail.jstrends "top movers" card09-trends.jsteam tree member list
All were already escaped at the call site; this moves the escaping to the trust boundary so it cannot be forgotten. The d3 .text() node label, textContent title, clipboard text and comma-separated-value export keep the raw helpers (they are not HTML sinks).
Content-Security-Policy role
internal/renderer/static/_headers sets script-src 'self' with no unsafe-inline. On GitLab Pages this already blocks inline onerror= and injected <script> from executing, so the escaping is defense in depth: it stops attribute injection and layout breaking, and protects hosts that do not honor _headers (local file:// open, other static hosts). The CSP is a real backstop, not the only line.
Fix
- Add
userDisplayHtml(userId)andprojectDisplayHtml(projectId)(in01-core.js) that wrap the raw display helpers inescapeHtml. Route every identityinnerHTMLsink through them. - Extend
escapeHtml(in10-ui.js) to also escape the single quote ('to') so it is safe in single-quoted attribute contexts; it previously escaped only& < > ". - Document
showTooltipas a raw-HTML sink and addshowTooltipTextas the escaped plain-text primitive. - Source of truth is
src/*.js, bundled intoassets/app.src.jsbymake js-bundle. Bundle regenerated.assets/app.jsis a git-ignored build artifact; the runtime loadsapp.src.js.
Structural test
TestRender_DashboardJSEscapesInstanceData asserts over the embedded bundle that escapeHtml escapes the single quote, that the *Html helpers exist and wrap escapeHtml, that the known sinks route through them, and that the old escapeHtml(userDisplay(...)) verbose forms are gone. Each assertion fails on the pre-hardening bundle. TestRender_CraftedPayloadCopiedVerbatim confirms the renderer carries payloads through to the data files (the dashboard escapes at the DOM, the renderer does not pre-escape server-side).
Browser verification
Rendered a fixture with crafted payloads (display name <img src=x onerror=alert(1)>, project name "><script>alert(2)</script>) to /tmp/manifold-xss/public. Load /tmp/manifold-xss/public/index.html in a browser: the payloads must render as inert text on the Profiles, Influence, and Trends tabs, not execute.
Validation
go vet clean; go test -race ./... pass; golangci-lint run 0 issues; coverage 69.9%.