Escape what the monitor page renders, and put the helpers where every page can reach them

The page interpolated media titles, partner hostnames read over the mesh, and docker folder
names straight into innerHTML — its own header claimed otherwise, and the helpers that would
have fixed it were defined in a page it never loads. Container WebUI values now get a scheme
check before they reach window.open().
This commit is contained in:
Gmer4Lfe
2026-08-07 10:17:45 -04:00
parent b02857cf04
commit fb50f94ed8
4 changed files with 126 additions and 57 deletions
+44
View File
@@ -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
// <script src> *below* the tab include, so it is not defined yet while a page's inline script is
// running. The shared formatters there survive only because every caller is inside a fetch
// callback. An escaping helper must be callable from the first synchronous line of a page.
//
// String() rather than assuming a string: these are fed payload fields that are frequently
// numbers, and sometimes null or undefined. A helper that throws on a number is a helper call
// sites will skip.
//
// Two functions because the contexts differ, and using the wrong one is silent:
// vvEscHtml text between tags. Leaves " alone — harmless there.
// vvEscAttr text inside an attribute. Escapes " as well, because a quote inside a
// double-quoted attribute ends it early and destroys the rest of the handler.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
function vvEscHtml(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function vvEscAttr(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/"/g,'&quot;')
.replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// A URL about to be put in href/src or handed to window.open. Anything that is not plainly http,
// https or a site-relative path becomes empty — javascript: is the one that matters, and an
// allowlist is the only way to say that without chasing encodings. include/docs.php applies the
// same rule to markdown links; container WebUI values, which come from template XML, had no such
// check before reaching window.open().
function vvSafeUrl(u) {
const s = String(u ?? '').trim();
if (s === '') return '';
if (/^https?:\/\//i.test(s)) return s;
if (/^\/(?!\/)/.test(s)) return s;
return '';
}
</script>
<?php
+7
View File
@@ -44,6 +44,13 @@ function vv_container_webui(string $name, array $portMap): string {
return $portMap[$name][$pm[1]] ?? $pm[1];
}, $url);
// Scheme allowlist, applied here so a bad value never reaches the page rather than being
// filtered at each sink. A WebUI entry is http or https in every real template; anything else
// is either broken or a javascript: URL aimed at whoever clicks it. These files come from
// Community Applications and hand edits, so they are not ours to trust. include/docs.php
// applies the same rule to markdown links for the same reason.
if (!preg_match('#^https?://#i', $url)) return '';
return $url;
}
+71 -45
View File
@@ -31,6 +31,16 @@
// against real inventory.
//
// All remote and container-supplied strings render escaped.
// Through vvEscHtml()/vvEscAttr() from Varaverk.page — media titles and usernames from
// Emby/Jellyfin/Plex, partner hostnames and versions read over the mesh, docker folder
// names, VM names, UPS and GPU model strings. This line claimed to be true before any of
// it was: the page had no escaping at all, and the helpers it needed lived in a page it
// never loads.
//
// A container WebUI value is scheme-checked before it is a link.
// include/docker_folders.php allows only http/https out of the template XML, and
// vvSafeUrl() filters again at window.open(), where a javascript: URL would run with
// this page's origin.
//
// RENDERS
// System header, CPU per core, memory breakdown, GPU cards, storage pools and array disks,
@@ -477,7 +487,7 @@ function vvRenderMemory(mem) {
const procs = mem.top_procs ?? [];
const procStrip = procs.map(p =>
`<span style="white-space:nowrap;">${p.name}&nbsp;<span style="color:#aaa;font-weight:600;">${vvFmtGib(p.kb)}</span></span>`
`<span style="white-space:nowrap;">${vvEscHtml(p.name)}&nbsp;<span style="color:#aaa;font-weight:600;">${vvFmtGib(p.kb)}</span></span>`
).join('<span style="color:#333;margin:0 5px;">·</span>');
let html = `<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px;">
@@ -664,7 +674,7 @@ function vvDiskRow(disk) {
: `<span style="color:#555;font-size:10px;">${vvFmt(disk.used_gb)} / ${vvFmt(disk.size_gb)}</span>`;
return `<div style="margin-bottom:7px;">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px;">
<span style="color:${nameColor};display:flex;align-items:center;">${disk.name}${spinLabel}${failLabel}${vvIoChip(disk.device)}</span>
<span style="color:${nameColor};display:flex;align-items:center;">${vvEscHtml(disk.name)}${spinLabel}${failLabel}${vvIoChip(disk.device)}</span>
${right}
<span style="color:${tempColor};font-size:10px;margin-left:6px;flex-shrink:0;">${tempStr}</span>
</div>
@@ -741,7 +751,7 @@ function vvPollMonitor() {
document.getElementById('vv-system-body').innerHTML =
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
<div style="min-width:0;">
<div style="font-size:14px;font-weight:700;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${sys.name}</div>
<div style="font-size:14px;font-weight:700;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${vvEscHtml(sys.name)}</div>
<div style="font-size:10px;color:#555;margin-top:2px;">${sys.comment || '&nbsp;'}</div>
</div>
<div style="display:flex;align-items:flex-start;gap:6px;flex-shrink:0;margin-left:6px;">
@@ -771,9 +781,9 @@ function vvPollMonitor() {
<div style="font-size:20px;font-weight:300;color:#ccc;line-height:1;">${timeStr}</div>
<div style="font-size:10px;color:#555;margin-bottom:10px;">${dateStr} &middot; ${tz}</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 8px;font-size:11px;">
<span style="color:#444;">Model</span> <span style="color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${sys.cpu_model}${_coreMeta}</span>
<span style="color:#444;">Array</span> <span style="color:${arrayColor};font-weight:600;">${sys.array_state}</span>
<span style="color:#444;">Uptime</span> <span style="color:#888;">${sys.uptime}</span>
<span style="color:#444;">Model</span> <span style="color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${vvEscHtml(sys.cpu_model)}${_coreMeta}</span>
<span style="color:#444;">Array</span> <span style="color:${arrayColor};font-weight:600;">${vvEscHtml(sys.array_state)}</span>
<span style="color:#444;">Uptime</span> <span style="color:#888;">${vvEscHtml(sys.uptime)}</span>
<span style="color:#444;">Load</span> <span style="color:${_loadColor};">${_loadStr}</span>
<span style="color:#444;">Running</span> <span style="color:#888;">${_runningCtrs} ctr${_runningCtrs !== 1 ? 's' : ''}${_runningVMs > 0 ? ` · ${_runningVMs} VM` : ''}</span>
<span style="color:#444;">Version</span> <span style="color:#3a3a3a;">${ver}</span>
@@ -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 ? `<span style="color:hsl(${cpuHue},70%,45%);font-weight:600;">${rs.cpu_load}%${rs.cpu_threads ? `<span style="color:#333;font-weight:400;"> · ${rs.cpu_threads}t</span>` : ''}</span>` : `<span style="color:#333;">—</span>`;
const ramStr = ramAvail ? `<span style="color:hsl(${memHue},70%,45%);font-weight:600;">${rs.mem_used_pct}%${rs.mem_total_gb ? `<span style="color:#333;font-weight:400;"> · ${rs.mem_total_gb}G</span>` : ''}</span>` : `<span style="color:#333;">—</span>`;
const cpuStr = cpuAvail ? `<span style="color:hsl(${cpuHue},70%,45%);font-weight:600;">${vvEscHtml(rs.cpu_load)}%${rs.cpu_threads ? `<span style="color:#333;font-weight:400;"> · ${vvEscHtml(rs.cpu_threads)}t</span>` : ''}</span>` : `<span style="color:#333;">—</span>`;
const ramStr = ramAvail ? `<span style="color:hsl(${memHue},70%,45%);font-weight:600;">${vvEscHtml(rs.mem_used_pct)}%${rs.mem_total_gb ? `<span style="color:#333;font-weight:400;"> · ${vvEscHtml(rs.mem_total_gb)}G</span>` : ''}</span>` : `<span style="color:#333;">—</span>`;
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
? `<div style="font-size:10px;color:#ff9800;margin-top:4px;padding:3px 6px;background:#1a1000;border:1px solid #3a2800;border-radius:3px;">
⚠ Version mismatch: local ${myVer} · remote ${remoteVer}<br>
⚠ Version mismatch: local ${vvEscHtml(myVer)} · remote ${vvEscHtml(remoteVer)}<br>
<span style="color:#555;">Script sync ops are gated until versions match</span>
</div>` : '';
const verRow = remoteVer ? `<span style="color:#444;">unRAID</span><span style="color:#3a3a3a;grid-column:span 3;">${remoteVer}</span>` : '';
const verRow = remoteVer ? `<span style="color:#444;">unRAID</span><span style="color:#3a3a3a;grid-column:span 3;">${vvEscHtml(remoteVer)}</span>` : '';
statsHtml = `<div style="display:grid;grid-template-columns:auto 1fr auto 1fr;gap:2px 8px;font-size:10px;margin-top:5px;margin-bottom:2px;">
<span style="color:#444;">CPU</span>${cpuStr}
<span style="color:#444;">RAM</span>${ramStr}
<span style="color:#444;">Array</span>
<span style="color:${arrColor};font-weight:600;">${rs.array_state}</span>
<span style="color:${arrColor};font-weight:600;">${vvEscHtml(rs.array_state)}</span>
<span style="color:#444;">Uptime</span>
<span style="color:#555;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${uptimeStr}</span>
<span style="color:#555;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${vvEscHtml(uptimeStr)}</span>
${verRow}
</div>${verWarn}`;
} else if (rs && rs.no_api_key) {
@@ -846,9 +856,9 @@ function vvPollMonitor() {
ptHtml += `<div style="margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid #222;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<div>
<span style="font-size:10px;color:#555;margin-right:4px;">${h.id}</span>
<span style="font-size:12px;color:#ccc;font-weight:500;">${h.owner || h.hostname}</span>${tags}
<div style="font-size:10px;color:#555;margin-top:1px;">${h.hostname}</div>
<span style="font-size:10px;color:#555;margin-right:4px;">${vvEscHtml(h.id)}</span>
<span style="font-size:12px;color:#ccc;font-weight:500;">${vvEscHtml(h.owner || h.hostname)}</span>${tags}
<div style="font-size:10px;color:#555;margin-top:1px;">${vvEscHtml(h.hostname)}</div>
${onboardBadge}
</div>
<span style="color:${dot};font-size:10px;white-space:nowrap;">● ${label}</span>
@@ -952,13 +962,13 @@ function vvPollMonitor() {
if (fbActive.length) {
fbActive.forEach(group => {
fbHtml += `<div style="font-size:10px;color:#555;margin-bottom:4px;letter-spacing:.03em;">
COVERING ${group.hostname}
COVERING ${vvEscHtml(group.hostname)}
</div>`;
group.containers.forEach(c => {
const img = c.image.includes('/') ? c.image.split('/').pop() : c.image;
fbHtml += `<div style="display:flex;justify-content:space-between;align-items:center;
background:#1a1a1a;border-radius:4px;padding:4px 8px;margin-bottom:3px;">
<span style="color:#ccc;font-size:11px;font-weight:500;">${c.name}</span>
<span style="color:#ccc;font-size:11px;font-weight:500;">${vvEscHtml(c.name)}</span>
<span style="color:#444;font-size:9px;margin-left:8px;white-space:nowrap;">${img}</span>
</div>`;
});
@@ -1022,8 +1032,8 @@ function vvPollMonitor() {
document.getElementById('vv-ups-body').innerHTML =
`<div class="vv-banner ${statCls}" style="margin-bottom:8px;">
<span>${ups.status}${onBatt ? ' — ON BATTERY' : ''}</span>
<span style="font-size:11px;font-weight:400;opacity:0.8;">${ups.model}</span>
<span>${vvEscHtml(ups.status)}${onBatt ? ' — ON BATTERY' : ''}</span>
<span style="font-size:11px;font-weight:400;opacity:0.8;">${vvEscHtml(ups.model)}</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px;">
<div>
@@ -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 += `<div style="display:flex;justify-content:space-between;font-size:10px;color:#666;margin-bottom:2px;">
<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${r.name}</span>
<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${vvEscHtml(r.name)}</span>
<span style="flex-shrink:0;margin-left:6px;color:#444;">${ago}</span>
</div>`;
});
@@ -1494,7 +1504,7 @@ function vvPollMonitor() {
html += `<div style="background:#0d1a0a;border:1px solid #1a3a0a;border-radius:3px;
padding:4px 8px;margin-bottom:5px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px;">
<span style="font-size:10px;color:#8bc34a;font-weight:600;">⟳ ${a.profile}</span>
<span style="font-size:10px;color:#8bc34a;font-weight:600;">⟳ ${vvEscHtml(a.profile)}</span>
<span style="font-size:10px;color:#6a8a4a;">${_dur(sec)}</span>
</div>
<div style="height:3px;background:#0a0a0a;border-radius:2px;overflow:hidden;">
@@ -1529,7 +1539,7 @@ function vvPollMonitor() {
html += `<div style="margin-bottom:4px;">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:2px;">
<span style="font-size:10px;color:#555;width:46px;flex-shrink:0;">${meta.label}</span>
<span style="font-size:10px;color:#555;width:46px;flex-shrink:0;">${vvEscHtml(meta.label)}</span>
<span style="font-size:9px;color:#333;flex:1;text-align:right;margin-right:6px;">
${s?.duration ? _dur(s.duration) : ''}</span>
<span style="font-size:9px;color:#2a2a2a;width:28px;text-align:right;margin-right:5px;">
@@ -1607,7 +1617,7 @@ function vvPollMonitor() {
bodyEl.innerHTML =
// header row: name + process count pill
`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<div style="font-size:11px;color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${gpu.name}</div>
<div style="font-size:11px;color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${vvEscHtml(gpu.name)}</div>
<div style="margin-left:8px;background:${procColor}22;border:1px solid ${procColor};color:${procColor};
padding:1px 8px;border-radius:10px;font-size:11px;white-space:nowrap;">${procCount} proc${procCount !== 1 ? 's' : ''}</div>
</div>` +
@@ -1699,7 +1709,7 @@ function vvPollMonitor() {
const meth = s.method.replace('Transcode', 'TC').replace('Direct ', '');
return `<div style="display:flex;align-items:center;gap:5px;margin-bottom:4px;min-width:0;overflow:hidden;">
${vvSrvIcon(s.server_type, s.server)}
<span style="font-size:10px;color:#888;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${s.title}</span>
<span style="font-size:10px;color:#888;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${vvEscHtml(s.title)}</span>
<span style="font-size:9px;color:#555;flex-shrink:0;">${typeLabel(s.type)}</span>
<span style="font-size:9px;color:#ff9800;flex-shrink:0;">${meth}</span>
</div>`;
@@ -1799,15 +1809,15 @@ function vvPollFast() {
const peakTx = Math.max(...vvNetTxHistory, 0);
const ipRows = [
net.local_ip ? `<div><span style="color:#555;font-size:9px;">LAN&nbsp;&nbsp;</span>${net.local_ip}</div>` : '',
net.ext_ip ? `<div><span style="color:#555;font-size:9px;">EXT&nbsp;&nbsp;</span>${net.ext_ip}</div>` : '',
net.ts_ip ? `<div><span style="color:#555;font-size:9px;">TS&nbsp;&nbsp;&nbsp;</span>${net.ts_ip}</div>` : '',
net.local_ip ? `<div><span style="color:#555;font-size:9px;">LAN&nbsp;&nbsp;</span>${vvEscHtml(net.local_ip)}</div>` : '',
net.ext_ip ? `<div><span style="color:#555;font-size:9px;">EXT&nbsp;&nbsp;</span>${vvEscHtml(net.ext_ip)}</div>` : '',
net.ts_ip ? `<div><span style="color:#555;font-size:9px;">TS&nbsp;&nbsp;&nbsp;</span>${vvEscHtml(net.ts_ip)}</div>` : '',
].filter(Boolean).join('');
document.getElementById('vv-network-body').innerHTML =
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
<div>
<div style="font-size:12px;color:#888;margin-bottom:4px;">${net.iface} &nbsp;·&nbsp; ${linkLabel}</div>
<div style="font-size:12px;color:#888;margin-bottom:4px;">${vvEscHtml(net.iface)} &nbsp;·&nbsp; ${linkLabel}</div>
<div style="display:flex;gap:16px;font-size:13px;font-weight:600;">
<span><span style="color:#4caf50;font-size:9px;margin-right:4px;">━ IN (RX)</span><span style="color:#4caf50;">${vvFmtBps(rx)}</span><span style="color:#444;font-size:9px;font-weight:400;margin-left:4px;">peak ${vvFmtBps(peakRx)}</span></span>
<span><span style="color:#ff9800;font-size:9px;margin-right:4px;">━ OUT (TX)</span><span style="color:#ff9800;">${vvFmtBps(tx)}</span><span style="color:#444;font-size:9px;font-weight:400;margin-left:4px;">peak ${vvFmtBps(peakTx)}</span></span>
@@ -1886,8 +1896,8 @@ function vvRenderStreams() {
const badges = names.map(n => {
const cnt = serverCounts[n] ?? 0;
return cnt > 0
? `<span class="vv-server-badge">${n} <b style="color:#ccc;">${cnt}</b></span>`
: `<span class="vv-server-badge" style="color:#444;">${n}</span>`;
? `<span class="vv-server-badge">${vvEscHtml(n)} <b style="color:#ccc;">${cnt}</b></span>`
: `<span class="vv-server-badge" style="color:#444;">${vvEscHtml(n)}</span>`;
}).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 `<span class="${cls}"${vvChipShade(cls, i)}>${label}</span>`;
return `<span class="${cls}"${vvChipShade(cls, i)}>${vvEscHtml(label)}</span>`;
}
// Device type summary — e.g. "3 Android 1 iOS 2 Roku"
@@ -2008,11 +2021,11 @@ function vvRenderStreams() {
return `<div>
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:2px;">
<span style="color:${s.paused ? '#fdd835' : '#aaa'};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">
<span style="color:${iconColor};">${icon}</span> ${s.title}</span>
<span style="color:#444;font-size:10px;margin-left:6px;flex-shrink:0;">${s.server}</span>
<span style="color:${iconColor};">${icon}</span> ${vvEscHtml(s.title)}</span>
<span style="color:#444;font-size:10px;margin-left:6px;flex-shrink:0;">${vvEscHtml(s.server)}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:10px;color:#555;margin-bottom:3px;">
<span>${s.user}</span>
<span>${vvEscHtml(s.user)}</span>
${timeStr}
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
@@ -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 += `<div class="vv-df-vm-row">
<span class="vv-df-vm-icon">${osIcon(vm.os)}</span>
<span style="width:7px;height:7px;border-radius:50%;background:${sc};flex-shrink:0;${pulse}"></span>
<span class="vv-df-cname">${vm.name}</span>
<span class="vv-df-cname">${vvEscHtml(vm.name)}</span>
<span style="font-size:11px;font-weight:600;color:${sc};flex-shrink:0;">${stateLabel(vm.state)}</span>
${meta}
</div>`;
@@ -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 `<div class="vv-df-container${active ? ' vv-df-active' : ''}"
onclick="event.stopPropagation();vvToggleContainer('${sn}')">
<span class="vv-df-dot" style="background:${dot};${pulse}"></span>
<span class="vv-df-cname">${c.name}</span>
<span class="vv-df-status">${sShort}</span>
<span class="vv-df-cname">${vvEscHtml(c.name)}</span>
<span class="vv-df-status">${vvEscHtml(sShort)}</span>
</div>${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 = `<span style="font-size:10px;color:${bColor};flex-shrink:0;margin-left:auto;">${running}/${total}</span>`;
const sid = f.id.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
const sid = vvEscAttr(String(f.id ?? '').replace(/\\/g,'\\\\').replace(/'/g,"\\'"));
let iconHtml = '';
if (f.isEmoji) {
iconHtml = `<span style="font-size:11px;flex-shrink:0;">${f.icon}</span>`;
} else if (f.icon) {
iconHtml = `<img src="${f.icon}" style="width:13px;height:13px;object-fit:contain;border-radius:2px;flex-shrink:0;" onerror="this.style.display='none'">`;
iconHtml = `<span style="font-size:11px;flex-shrink:0;">${vvEscHtml(f.icon)}</span>`;
} else if (vvSafeUrl(f.icon)) {
iconHtml = `<img src="${vvEscAttr(vvSafeUrl(f.icon))}" style="width:13px;height:13px;object-fit:contain;border-radius:2px;flex-shrink:0;" onerror="this.style.display='none'">`;
}
let out = `<div class="vv-df-folder">
<div class="vv-df-folder-hdr" onclick="vvToggleFolder('${sid}')">
<span class="vv-df-chevron">${open ? '▾' : '▸'}</span>
${iconHtml}
<span class="vv-df-fname">${f.name}</span>
<span class="vv-df-fname">${vvEscHtml(f.name)}</span>
${badge}
</div>`;
if (open) {
+4 -12
View File
@@ -1158,18 +1158,10 @@ if (vvOpenScriptId) {
});
}
function vvEscHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// 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,'&amp;').replace(/"/g,'&quot;')
.replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// 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});