The public, CSP-exempt widget render (GET /api/widgets/:id/render) inlined
config values straight into <style>/CSS and (for the text widget) raw into the
same-origin document. A workspace editor could store `}</style><script>...` in a
color/background/size field (bypassing the UI pickers via the API) → stored XSS
executing in the app origin for anyone who opens the render URL (JWT theft).
- safeCss(): allow colors/gradients but reject CSS breakout / url() / @import /
expression / javascript:. Applied to background/color across clock, weather,
rss, social renders.
- safeNumber(): coerce font_size / scroll_speed / max_items to a finite number
so they can't smuggle markup.
- Text widget keeps its intentional raw HTML/CSS feature, but it now renders
inside an <iframe sandbox="allow-scripts"> (NO allow-same-origin) - scripts run
in a null origin that can't reach the dashboard's localStorage/JWT.
Tests: test/widget-render-xss.test.js (breakout rejected, numbers coerced, text
isolated, legit colors/gradients preserved). Full suite green.