From ec450929ce7ca0bb5440b8f9568bed4d77043c34 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 11 Aug 2026 11:44:10 -0500 Subject: [PATCH] Escape user-controlled data at the HTML sinks it actually reaches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A QA sweep found unescaped interpolations outside the SSO work. Auditing them properly turned up 34 genuine HTML sinks; 23 carry data a user, a device or an identity provider controls, and those are escaped here. The ones that mattered: - app.js renders `user.name` in the shell on EVERY page, and an identity provider's `name` claim is stored verbatim, so an IdP could script the whole dashboard - designer element `label`/`location` and widget `location`/`query` land inside value="" attributes, where a single quote breaks out - content `folder` lands in a data-folder="" attribute - device `name` is set by the operator OR reported by the panel itself - workspace-members renders a SERVER error string through t(), which interpolates raw ⚠️ My first attempt was a codemod over everything my scanner flagged, and it was wrong. It wrapped `progressText.textContent`, `block.title` and `confirm(...)` — none of which are HTML, so escaping there shows users literal `<`. Worse, it wrapped `title: ev.title ? ... : null`, an API PAYLOAD, which would have written escaped markup into the database. I reverted the whole thing and narrowed to interpolations that are genuinely inside an HTML template, then read all 34 and chose 23. Skipped deliberately: static app strings, i18n output, ternaries yielding `selected`, `window.location.origin`, and sites already escaped. Verified in Chrome, not by inspection: the payload was seeded into user.name, device.name, content.filename/folder, widget.name/config and video_wall.name (the first attempt's seeds silently failed on column names — the API responses are checked now), then eleven views were loaded. Zero executions, zero live img tags — AND the payload is visible as inert text in 6/6 views, which is what proves the views rendered it rather than the test proving nothing. 1609 tests; every frontend module parses as an ES module. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A --- frontend/js/app.js | 5 +++-- frontend/js/views/admin.js | 2 +- frontend/js/views/billing.js | 4 ++-- frontend/js/views/content-library.js | 2 +- frontend/js/views/designer.js | 8 ++++---- frontend/js/views/device-detail.js | 8 ++++---- frontend/js/views/reports.js | 6 +++--- frontend/js/views/settings.js | 2 +- frontend/js/views/video-wall.js | 2 +- frontend/js/views/widgets.js | 6 +++--- frontend/js/views/workspace-members.js | 2 +- 11 files changed, 24 insertions(+), 23 deletions(-) diff --git a/frontend/js/app.js b/frontend/js/app.js index 968cef4..25ed476 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -28,6 +28,7 @@ import { isPlatformAdmin } from './utils.js'; import { renderWorkspaceSwitcher } from './components/workspace-switcher.js'; import { showToast } from './components/toast.js'; import { api } from './api.js'; +import { esc } from './utils.js'; const app = document.getElementById('app'); const sidebar = document.querySelector('.sidebar'); @@ -594,9 +595,9 @@ function updateSidebarUser() { userEl.innerHTML = ` ${user.avatar_url ? `` : - `
${(user.name || user.email)[0].toUpperCase()}
`} + `
${esc((user.name || user.email)[0].toUpperCase())}
`}
-
${user.name || user.email}
+
${esc(user.name || user.email)}
${user.role}