fix(ui): close CodeQL js/xss-through-dom finding (PR #240 review)

CodeQL alert 132 flagged the reboot status line as a sink that received
user-controlled DOM text (device names from the migration/sync select
options and table rows) without escaping. Six data-flow paths converged
on script.js:1950.

Switch the sink at line 1950 from .innerHTML to .textContent — the
status message has never needed HTML formatting. The pre-existing
display-into-innerHTML pattern still exists elsewhere in this file but
those lines aren't in this PR's scope and are tracked by their own
historical alerts.

Also harden the (newer) `currentP.innerHTML = ... <strong> + data.current
+ </strong> ...` line in loadAccountIDSuggestions: rebuild the paragraph
with replaceChildren + createElement so the account ID never becomes
HTML, even though it's expected to be a 7-digit string.

Coerce known account IDs to String() when populating the existing-account
dropdown so the IDE's type inference stops complaining about
opt.value = id; / opt.textContent = id; on data of unknown[] type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-10 12:43:36 +02:00
co-authored by Claude Opus 4.7
parent 0b579a7e59
commit e3dac8b5a6
+12 -8
View File
@@ -1947,7 +1947,8 @@ async function reboot(deviceId, ip) {
const statusDiv = document.getElementById("status");
statusDiv.style.display = "block";
statusDiv.style.backgroundColor = "#ffffcc";
statusDiv.innerHTML = "Rebooting " + display + " via " + rebootMethod + "...";
// textContent avoids reinterpreting the (user-controlled) device name as HTML.
statusDiv.textContent = "Rebooting " + display + " via " + rebootMethod + "...";
try {
const url = "/setup/reboot/" + encodeURIComponent(deviceId)
@@ -1993,19 +1994,22 @@ async function loadAccountIDSuggestions(deviceId) {
// Reset
existingSelect.innerHTML = "<option value=\"\">-- pick from datastore --</option>";
(data.known || []).forEach((id) => {
(data.known || []).forEach((/** @type {string} */ id) => {
const opt = document.createElement("option");
opt.value = id;
opt.textContent = id;
opt.value = String(id);
opt.textContent = String(id);
existingSelect.appendChild(opt);
});
if (data.current) {
currentP.style.display = "block";
currentP.innerHTML =
"Speaker is already paired with account "
+ "<strong>" + data.current + "</strong>"
+ ". You can keep it (recommended) or re-pair to a different ID.";
// Build the paragraph with createElement so the user-controlled
// account ID never becomes HTML.
currentP.replaceChildren(
document.createTextNode("Speaker is already paired with account "),
Object.assign(document.createElement("strong"), {textContent: data.current}),
document.createTextNode(". You can keep it (recommended) or re-pair to a different ID."),
);
input.value = data.current;
freshDiv.style.display = "block";
} else {