fix(web): escape registry and upload values in plugin manager inline handlers

The store, saved-repository and custom-registry buttons built
onclick='...(${JSON.stringify(id)})...'. JSON.stringify leaves ' alone,
so a custom registry entry whose id contained ' closed the attribute and
added its own handler. One helper, jsStringAttr(), now HTML-escapes the
JSON literal for every one of those handlers, and the store View button
opens only http(s) repo links.

The live window.updateImageList (plugins_manager.js loads last, so its
copy wins over the file-upload widget's) wrote the uploaded file's
original name, path and ids into markup raw; they are escaped now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-17 08:50:09 -04:00
co-authored by Claude Opus 5
parent 15cc6926a4
commit 68f267143b
4 changed files with 242 additions and 15 deletions
+25 -14
View File
@@ -3893,13 +3893,15 @@ function renderPluginStore(plugins) {
return;
}
// Helper function to escape for JavaScript strings
// JS string literal for an inline handler; see jsStringAttr
const escapeJs = (text) => {
return JSON.stringify(text || '');
return jsStringAttr(text || '');
};
setGridHtmlIfChanged(container, plugins.map(plugin => {
const installed = isStorePluginInstalled(plugin);
// Registry data: only open real web links, never javascript: URLs.
const repoLink = plugin.repo && /^https?:\/\//i.test(plugin.repo) ? plugin.repo : '';
return `
<div class="plugin-card">
<div class="flex items-start justify-between mb-4">
@@ -3941,7 +3943,7 @@ function renderPluginStore(plugins) {
<button onclick='if(window.installPlugin){const branchInput = document.getElementById("branch-input-${plugin.id.replace(/[^a-zA-Z0-9]/g, '-')}"); window.installPlugin(${escapeJs(plugin.id)}, branchInput?.value?.trim() || null)}else{console.error("installPlugin not available")}' class="btn ${installed ? 'bg-gray-500 hover:bg-gray-600' : 'bg-green-600 hover:bg-green-700'} text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold">
<i class="fas ${installed ? 'fa-redo' : 'fa-download'} mr-2"></i>${installed ? 'Reinstall' : 'Install'}
</button>
<button onclick='${plugin.repo ? `window.open(${escapeJs(plugin.plugin_path ? plugin.repo + "/tree/" + encodeURIComponent(plugin.default_branch || plugin.branch || "main") + "/" + plugin.plugin_path.split("/").map(encodeURIComponent).join("/") : plugin.repo)}, "_blank")` : `void(0)`}' ${plugin.repo ? '' : 'disabled'} class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold${plugin.repo ? '' : ' opacity-50 cursor-not-allowed'}">
<button onclick='${repoLink ? `window.open(${escapeJs(plugin.plugin_path ? repoLink + "/tree/" + encodeURIComponent(plugin.default_branch || plugin.branch || "main") + "/" + plugin.plugin_path.split("/").map(encodeURIComponent).join("/") : repoLink)}, "_blank")` : `void(0)`}' ${repoLink ? '' : 'disabled'} class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold${repoLink ? '' : ' opacity-50 cursor-not-allowed'}">
<i class="fas fa-external-link-alt mr-2"></i>View
</button>
</div>
@@ -4102,9 +4104,9 @@ function renderSavedRepositories(repositories) {
return;
}
// Helper function to escape for JavaScript strings
// JS string literal for an inline handler; see jsStringAttr
const escapeJs = (text) => {
return JSON.stringify(text || '');
return jsStringAttr(text || '');
};
container.innerHTML = repositories.map(repo => {
@@ -4498,9 +4500,9 @@ function renderCustomRegistryPlugins(plugins, registryUrl) {
.replace(/'/g, '&#39;');
};
// Helper function to escape for JavaScript strings
// JS string literal for an inline handler; see jsStringAttr
const escapeJs = (text) => {
return JSON.stringify(text || '');
return jsStringAttr(text || '');
};
container.innerHTML = plugins.map(plugin => {
@@ -4617,6 +4619,15 @@ function escapeAttribute(text) {
.replace(/>/g, '&gt;');
}
// A quoted JS string literal that is safe inside an inline handler attribute
// (onclick='f(${jsStringAttr(id)})' or onclick="..."). JSON.stringify alone
// makes a valid JS string but leaves ' and & untouched, so a registry entry
// id containing ' closed a single-quoted attribute and added its own
// handlers. The browser decodes the entities before the JS is parsed.
function jsStringAttr(value) {
return escapeAttribute(JSON.stringify(value == null ? '' : String(value)));
}
// Format date for display
function formatDate(dateString) {
if (!dateString) return 'Unknown';
@@ -5104,11 +5115,11 @@ window.updateImageList = function(fieldId, images) {
const scheduleSummary = hasSchedule ? (window.getScheduleSummary ? window.getScheduleSummary(imgSchedule) : 'Scheduled') : 'Always shown';
return `
<div id="img_${img.id || idx}" class="bg-gray-50 p-3 rounded-lg border border-gray-200">
<div id="img_${escapeAttribute(img.id || idx)}" class="bg-gray-50 p-3 rounded-lg border border-gray-200">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-3 flex-1">
<img src="/${img.path || ''}"
alt="${img.filename || ''}"
<img src="/${escapeAttribute(String(img.path || '').replace(/^\/+/, ''))}"
alt="${escapeAttribute(img.filename || '')}"
loading="lazy" decoding="async"
class="w-16 h-16 object-cover rounded"
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
@@ -5116,7 +5127,7 @@ window.updateImageList = function(fieldId, images) {
<i class="fas fa-image text-gray-400"></i>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">${img.original_filename || img.filename || 'Image'}</p>
<p class="text-sm font-medium text-gray-900 truncate">${escapeHtml(img.original_filename || img.filename || 'Image')}</p>
<p class="text-xs text-gray-500">${window.formatFileSize ? window.formatFileSize(img.size || 0) : (Math.round((img.size || 0) / 1024) + ' KB')} • ${window.formatDate ? window.formatDate(img.uploaded_at) : (img.uploaded_at || '')}</p>
<p class="text-xs text-blue-600 mt-1">
<i class="fas fa-clock mr-1"></i>${scheduleSummary}
@@ -5125,14 +5136,14 @@ window.updateImageList = function(fieldId, images) {
</div>
<div class="flex items-center space-x-2 ml-4">
<button type="button"
onclick="window.openImageSchedule('${fieldId}', '${img.id}', ${idx})"
onclick="window.openImageSchedule(${jsStringAttr(fieldId)}, ${jsStringAttr(img.id)}, ${idx})"
class="text-blue-600 hover:text-blue-800 p-2"
title="Schedule this image"
aria-label="Schedule image ${escapeAttribute(img.original_filename || img.filename || '')}">
<i class="fas fa-calendar-alt" aria-hidden="true"></i>
</button>
<button type="button"
onclick="window.deleteUploadedImage('${fieldId}', '${img.id}', '${pluginId}')"
onclick="window.deleteUploadedImage(${jsStringAttr(fieldId)}, ${jsStringAttr(img.id)}, ${jsStringAttr(pluginId)})"
class="text-red-600 hover:text-red-800 p-2"
title="Delete image"
aria-label="Delete image ${escapeAttribute(img.original_filename || img.filename || '')}">
@@ -5141,7 +5152,7 @@ window.updateImageList = function(fieldId, images) {
</div>
</div>
<!-- Schedule widget will be inserted here when opened -->
<div id="schedule_${img.id || idx}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
<div id="schedule_${escapeAttribute(img.id || idx)}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
</div>
`;
}).join('');