diff --git a/Plugin/unraid/Varaverk.page b/Plugin/unraid/Varaverk.page index bcb7eac..5859901 100644 --- a/Plugin/unraid/Varaverk.page +++ b/Plugin/unraid/Varaverk.page @@ -54,6 +54,50 @@ require_once "$pluginDir/include/config.php"; return nativeFetch(input, init); }; })(); + +// ═══════════════════════════════════════════════════════════════════════════════════════════════ +// Escaping helpers — shared, because every page builds HTML strings and assigns them to innerHTML. +// +// Defined here rather than per page for the reason the CSRF shim is: only one pages/*.php is ever +// included per request, so a helper defined inside one page does not exist for any other. That is +// not a hypothetical — these lived in pages/scheduler.php, and pages/monitor.php rendered media +// titles, partner hostnames and docker folder names straight into innerHTML with no escaping +// available to it at all. +// +// Not in js/varaverk.js, which would otherwise be the obvious home: that file is loaded by a +// - `${p.name} ${vvFmtGib(p.kb)}` + `${vvEscHtml(p.name)} ${vvFmtGib(p.kb)}` ).join('·'); let html = `
@@ -664,7 +674,7 @@ function vvDiskRow(disk) { : `${vvFmt(disk.used_gb)} / ${vvFmt(disk.size_gb)}`; return `
- ${disk.name}${spinLabel}${failLabel}${vvIoChip(disk.device)} + ${vvEscHtml(disk.name)}${spinLabel}${failLabel}${vvIoChip(disk.device)} ${right} ${tempStr}
@@ -741,7 +751,7 @@ function vvPollMonitor() { document.getElementById('vv-system-body').innerHTML = `
-
${sys.name}
+
${vvEscHtml(sys.name)}
${sys.comment || ' '}
@@ -771,9 +781,9 @@ function vvPollMonitor() {
${timeStr}
${dateStr} · ${tz}
- Model ${sys.cpu_model}${_coreMeta} - Array ${sys.array_state} - Uptime ${sys.uptime} + Model ${vvEscHtml(sys.cpu_model)}${_coreMeta} + Array ${vvEscHtml(sys.array_state)} + Uptime ${vvEscHtml(sys.uptime)} Load ${_loadStr} Running ${_runningCtrs} ctr${_runningCtrs !== 1 ? 's' : ''}${_runningVMs > 0 ? ` · ${_runningVMs} VM` : ''} Version ${ver} @@ -812,8 +822,8 @@ function vvPollMonitor() { const ramAvail = rs.mem_used_pct > 0; const cpuHue = cpuAvail ? Math.round(120 * (1 - rs.cpu_load / 100)) : 0; const memHue = ramAvail ? Math.round(120 * (1 - rs.mem_used_pct / 100)) : 0; - const cpuStr = cpuAvail ? `${rs.cpu_load}%${rs.cpu_threads ? ` · ${rs.cpu_threads}t` : ''}` : ``; - const ramStr = ramAvail ? `${rs.mem_used_pct}%${rs.mem_total_gb ? ` · ${rs.mem_total_gb}G` : ''}` : ``; + const cpuStr = cpuAvail ? `${vvEscHtml(rs.cpu_load)}%${rs.cpu_threads ? ` · ${vvEscHtml(rs.cpu_threads)}t` : ''}` : ``; + const ramStr = ramAvail ? `${vvEscHtml(rs.mem_used_pct)}%${rs.mem_total_gb ? ` · ${vvEscHtml(rs.mem_total_gb)}G` : ''}` : ``; const arrColor = rs.array_state === 'Started' || rs.array_state === 'STARTED' ? '#4caf50' : '#f44336'; const uptimeStr = rs.uptime && rs.uptime !== '—' ? rs.uptime : '—'; @@ -823,18 +833,18 @@ function vvPollMonitor() { const verMismatch = myVer && remoteVer && myVer !== remoteVer; const verWarn = verMismatch ? `
- ⚠ Version mismatch: local ${myVer} · remote ${remoteVer}
+ ⚠ Version mismatch: local ${vvEscHtml(myVer)} · remote ${vvEscHtml(remoteVer)}
Script sync ops are gated until versions match
` : ''; - const verRow = remoteVer ? `unRAID${remoteVer}` : ''; + const verRow = remoteVer ? `unRAID${vvEscHtml(remoteVer)}` : ''; statsHtml = `
CPU${cpuStr} RAM${ramStr} Array - ${rs.array_state} + ${vvEscHtml(rs.array_state)} Uptime - ${uptimeStr} + ${vvEscHtml(uptimeStr)} ${verRow}
${verWarn}`; } else if (rs && rs.no_api_key) { @@ -846,9 +856,9 @@ function vvPollMonitor() { ptHtml += `
- ${h.id} - ${h.owner || h.hostname}${tags} -
${h.hostname}
+ ${vvEscHtml(h.id)} + ${vvEscHtml(h.owner || h.hostname)}${tags} +
${vvEscHtml(h.hostname)}
${onboardBadge}
● ${label} @@ -952,13 +962,13 @@ function vvPollMonitor() { if (fbActive.length) { fbActive.forEach(group => { fbHtml += `
- COVERING ${group.hostname} + COVERING ${vvEscHtml(group.hostname)}
`; group.containers.forEach(c => { const img = c.image.includes('/') ? c.image.split('/').pop() : c.image; fbHtml += `
- ${c.name} + ${vvEscHtml(c.name)} ${img}
`; }); @@ -1022,8 +1032,8 @@ function vvPollMonitor() { document.getElementById('vv-ups-body').innerHTML = `
- ${ups.status}${onBatt ? ' — ON BATTERY' : ''} - ${ups.model} + ${vvEscHtml(ups.status)}${onBatt ? ' — ON BATTERY' : ''} + ${vvEscHtml(ups.model)}
@@ -1293,7 +1303,7 @@ function vvPollMonitor() { const diff = now - r.ts; const ago = diff < 3600 ? Math.floor(diff / 60) + 'm' : Math.floor(diff / 3600) + 'h'; html += `
- ${r.name} + ${vvEscHtml(r.name)} ${ago}
`; }); @@ -1494,7 +1504,7 @@ function vvPollMonitor() { html += `
- ⟳ ${a.profile} + ⟳ ${vvEscHtml(a.profile)} ${_dur(sec)}
@@ -1529,7 +1539,7 @@ function vvPollMonitor() { html += `
- ${meta.label} + ${vvEscHtml(meta.label)} ${s?.duration ? _dur(s.duration) : ''} @@ -1607,7 +1617,7 @@ function vvPollMonitor() { bodyEl.innerHTML = // header row: name + process count pill `
-
${gpu.name}
+
${vvEscHtml(gpu.name)}
${procCount} proc${procCount !== 1 ? 's' : ''}
` + @@ -1699,7 +1709,7 @@ function vvPollMonitor() { const meth = s.method.replace('Transcode', 'TC').replace('Direct ', ''); return `
${vvSrvIcon(s.server_type, s.server)} - ${s.title} + ${vvEscHtml(s.title)} ${typeLabel(s.type)} ${meth}
`; @@ -1799,15 +1809,15 @@ function vvPollFast() { const peakTx = Math.max(...vvNetTxHistory, 0); const ipRows = [ - net.local_ip ? `
LAN  ${net.local_ip}
` : '', - net.ext_ip ? `
EXT  ${net.ext_ip}
` : '', - net.ts_ip ? `
TS   ${net.ts_ip}
` : '', + net.local_ip ? `
LAN  ${vvEscHtml(net.local_ip)}
` : '', + net.ext_ip ? `
EXT  ${vvEscHtml(net.ext_ip)}
` : '', + net.ts_ip ? `
TS   ${vvEscHtml(net.ts_ip)}
` : '', ].filter(Boolean).join(''); document.getElementById('vv-network-body').innerHTML = `
-
${net.iface}  ·  ${linkLabel}
+
${vvEscHtml(net.iface)}  ·  ${linkLabel}
━ IN (RX)${vvFmtBps(rx)}peak ${vvFmtBps(peakRx)} ━ OUT (TX)${vvFmtBps(tx)}peak ${vvFmtBps(peakTx)} @@ -1886,8 +1896,8 @@ function vvRenderStreams() { const badges = names.map(n => { const cnt = serverCounts[n] ?? 0; return cnt > 0 - ? `${n} ${cnt}` - : `${n}`; + ? `${vvEscHtml(n)} ${cnt}` + : `${vvEscHtml(n)}`; }).join(''); // Per-chip shade: alternate bg brightness within a group to visually separate chips @@ -1902,8 +1912,11 @@ function vvRenderStreams() { const bg = (shades[cls] ?? ['',''])[i % 2]; return bg ? ` style="background:${bg};"` : ''; } + // Escapes here rather than at the four call sites: every one passes plain text, and the labels + // are not all ours — an unrecognised codec falls through vvCodecLabel() as the media server + // spelled it, and the device type is derived from the client string the player reports. function vvChip(cls, label, i) { - return `${label}`; + return `${vvEscHtml(label)}`; } // Device type summary — e.g. "3 Android 1 iOS 2 Roku" @@ -2008,11 +2021,11 @@ function vvRenderStreams() { return `
- ${icon} ${s.title} - ${s.server} + ${icon} ${vvEscHtml(s.title)} + ${vvEscHtml(s.server)}
- ${s.user} + ${vvEscHtml(s.user)} ${timeStr}
@@ -2223,7 +2236,14 @@ function vvToggleContainer(name) { } function vvDockerAction(action, name, webui) { - if (action === 'webui') { window.open(webui, '_blank'); return; } + // Filtered again at the point of use, not only where the button was built. This value originates + // in a container's template XML, and window.open() on a javascript: URL runs it with this page's + // origin — the one sink where an unchecked scheme is not merely a broken link. + if (action === 'webui') { + const u = vvSafeUrl(webui); + if (u) window.open(u, '_blank', 'noopener'); + return; + } if (action === 'edit') { window.location.href = '/Docker?action=template&xmlTemplate=' + encodeURIComponent('/boot/config/plugins/dockerMan/templates-user/my-' + name + '.xml') + '&update=true'; @@ -2270,7 +2290,7 @@ function vvRenderDockerFolders(data) { html += `
${osIcon(vm.os)} - ${vm.name} + ${vvEscHtml(vm.name)} ${stateLabel(vm.state)} ${meta}
`; @@ -2288,8 +2308,14 @@ function vvRenderDockerFolders(data) { const pulse = c.running ? 'animation:vv-pulse-dot 1s ease-in-out infinite;' : ''; const active = vvDfActive === c.name; const sShort = c.status ? c.status.replace(/^Up\s+/, '').split(' ').slice(0,2).join(' ') : '—'; - const sn = c.name.replace(/\\/g,'\\\\').replace(/'/g,"\\'"); - const sw = (c.webui||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); + // Escaped for two nested contexts at once: a JS string literal, and the double-quoted onclick + // attribute holding it. The previous version did the first half only — \ and ' — which leaves + // a " free to close the attribute and destroy every handler after it. Docker's own charset + // makes that unreachable through a container name, but the WebUI value comes from template + // XML and is under no such constraint. + const jsq = v => vvEscAttr(String(v ?? '').replace(/\\/g,'\\\\').replace(/'/g,"\\'")); + const sn = jsq(c.name); + const sw = jsq(vvSafeUrl(c.webui)); let actionBar = ''; if (active) { @@ -2309,8 +2335,8 @@ function vvRenderDockerFolders(data) { return `
- ${c.name} - ${sShort} + ${vvEscHtml(c.name)} + ${vvEscHtml(sShort)}
${actionBar}`; } @@ -2320,20 +2346,20 @@ function vvRenderDockerFolders(data) { const running = f.containers.filter(c => c.running).length; const bColor = running === total ? '#4caf50' : running === 0 ? '#555' : '#ff9800'; const badge = `${running}/${total}`; - const sid = f.id.replace(/\\/g,'\\\\').replace(/'/g,"\\'"); + const sid = vvEscAttr(String(f.id ?? '').replace(/\\/g,'\\\\').replace(/'/g,"\\'")); let iconHtml = ''; if (f.isEmoji) { - iconHtml = `${f.icon}`; - } else if (f.icon) { - iconHtml = ``; + iconHtml = `${vvEscHtml(f.icon)}`; + } else if (vvSafeUrl(f.icon)) { + iconHtml = ``; } let out = `
${open ? '▾' : '▸'} ${iconHtml} - ${f.name} + ${vvEscHtml(f.name)} ${badge}
`; if (open) { diff --git a/Plugin/unraid/pages/scheduler.php b/Plugin/unraid/pages/scheduler.php index 6513cbc..9821853 100644 --- a/Plugin/unraid/pages/scheduler.php +++ b/Plugin/unraid/pages/scheduler.php @@ -1158,18 +1158,10 @@ if (vvOpenScriptId) { }); } -function vvEscHtml(s) { - return s.replace(/&/g,'&').replace(//g,'>'); -} - -// For text going INSIDE a double-quoted attribute, which vvEscHtml does not cover: it leaves " -// alone, and a quote there ends the attribute early and silently destroys the handler after it. -// That is not a theoretical hazard — it shipped, and an onclick built from JSON.stringify() output -// was truncated to "vvErrOpenAtLine(" and did nothing at all when clicked. -function vvEscAttr(s) { - return String(s).replace(/&/g,'&').replace(/"/g,'"') - .replace(//g,'>'); -} +// vvEscHtml() and vvEscAttr() moved to Varaverk.page, which every tab loads — they were needed on +// pages that never include this one. The attribute variant exists because a " inside a +// double-quoted attribute ends it early and silently destroys the handler after it: that shipped +// once, truncating an onclick to "vvErrOpenAtLine(" so it did nothing at all when clicked. function vvPost(url, data) { const params = new URLSearchParams({csrf_token, ...data});