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.js profiles table name cell + title attribute
  • 07-influence.js influence ranking table cell, ego-network node and project tooltips
  • 05-groups.js enablement group member link
  • 08-detail.js trends "top movers" card
  • 09-trends.js team 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) and projectDisplayHtml(projectId) (in 01-core.js) that wrap the raw display helpers in escapeHtml. Route every identity innerHTML sink through them.
  • Extend escapeHtml (in 10-ui.js) to also escape the single quote (' to &#39;) so it is safe in single-quoted attribute contexts; it previously escaped only & < > ".
  • Document showTooltip as a raw-HTML sink and add showTooltipText as the escaped plain-text primitive.
  • Source of truth is src/*.js, bundled into assets/app.src.js by make js-bundle. Bundle regenerated. assets/app.js is a git-ignored build artifact; the runtime loads app.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%.

Merge request reports

Loading