diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py
index 0350dba7..73939f52 100644
--- a/web_interface/blueprints/pages_v3.py
+++ b/web_interface/blueprints/pages_v3.py
@@ -130,6 +130,9 @@ def serve_plugin_web_ui(plugin_id, filename):
if not safe_id or not safe_fn:
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
+ if not pages_v3.plugin_manager:
+ return 'Plugin manager not available', 503, {'Content-Type': 'text/plain'}
+
try:
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
diff --git a/web_interface/static/v3/js/widgets/array-table.js b/web_interface/static/v3/js/widgets/array-table.js
index 085b5e35..33e5980e 100644
--- a/web_interface/static/v3/js/widgets/array-table.js
+++ b/web_interface/static/v3/js/widgets/array-table.js
@@ -119,17 +119,21 @@
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (_FORBIDDEN_KEYS.has(parts[i])) return; // block prototype pollution
+ // eslint-disable-next-line security/detect-object-injection -- key validated by FORBIDDEN_KEYS
if (cur[parts[i]] === undefined || typeof cur[parts[i]] !== 'object') {
- cur[parts[i]] = {};
+ // Object.create(null) produces a null-prototype object that cannot be
+ // prototype-polluted via __proto__ assignment.
+ // eslint-disable-next-line security/detect-object-injection -- key validated above
+ cur[parts[i]] = Object.create(null);
}
+ // eslint-disable-next-line security/detect-object-injection -- key validated above
cur = cur[parts[i]];
}
const lastKey = parts[parts.length - 1];
- if (!_FORBIDDEN_KEYS.has(lastKey)) cur[lastKey] = value;
- }
-
- function getNestedValue(obj, path) {
- return path.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj);
+ if (!_FORBIDDEN_KEYS.has(lastKey)) {
+ // eslint-disable-next-line security/detect-object-injection -- key validated above
+ cur[lastKey] = value;
+ }
}
function coerceValue(strVal, typeHint) {
@@ -407,8 +411,6 @@
const schema = JSON.parse(advancedCell.dataset.propSchema || '{}');
const tbody = row.closest('tbody');
const fieldId = tbody ? tbody.id.replace('_tbody', '') : '';
- const rowIndex = parseInt(row.dataset.index, 10);
-
// Close any existing modal
const existing = document.getElementById('array-row-editor-modal');
if (existing) existing.remove();
@@ -446,6 +448,7 @@
// Section for nested object
const section = document.createElement('div');
section.className = 'border border-gray-200 rounded-lg p-3';
+ // eslint-disable-next-line no-unsanitized/property -- content sanitized by escapeHtml()
section.innerHTML = `
${escapeHtml(label)}
`;
const grid = document.createElement('div');
@@ -462,6 +465,7 @@
const currentVal = hiddenInput ? hiddenInput.value : (subSchema.default !== undefined ? subSchema.default : '');
const fieldDiv = document.createElement('div');
+ // eslint-disable-next-line no-unsanitized/property -- content sanitized by escapeHtml()
fieldDiv.innerHTML = ``;
fieldDiv.appendChild(buildModalInput(nestedPath, subSchema, subType, currentVal));
grid.appendChild(fieldDiv);
@@ -475,6 +479,7 @@
const currentVal = hiddenInput ? hiddenInput.value : (propSchema.default !== undefined ? propSchema.default : '');
const fieldDiv = document.createElement('div');
+ // eslint-disable-next-line no-unsanitized/property -- content sanitized by escapeHtml()
fieldDiv.innerHTML = ``;
fieldDiv.appendChild(buildModalInput(propName, propSchema, propType, currentVal));
body.appendChild(fieldDiv);
@@ -744,9 +749,9 @@
let displayColumns = [];
let fullItemProperties = {};
- try { itemProperties = JSON.parse(button.getAttribute('data-item-properties') || '{}'); } catch(e) {}
- try { displayColumns = JSON.parse(button.getAttribute('data-display-columns') || '[]'); } catch(e) {}
- try { fullItemProperties = JSON.parse(button.getAttribute('data-full-item-properties') || '{}'); } catch(e) { fullItemProperties = itemProperties; }
+ try { itemProperties = JSON.parse(button.getAttribute('data-item-properties') || '{}'); } catch(_e) {}
+ try { displayColumns = JSON.parse(button.getAttribute('data-display-columns') || '[]'); } catch(_e) {}
+ try { fullItemProperties = JSON.parse(button.getAttribute('data-full-item-properties') || '{}'); } catch(_e) { fullItemProperties = itemProperties; }
const tbody = document.getElementById(fieldId + '_tbody');
if (!tbody) return;
diff --git a/web_interface/static/v3/js/widgets/file-upload-single.js b/web_interface/static/v3/js/widgets/file-upload-single.js
index 5ab42542..668c26d8 100644
--- a/web_interface/static/v3/js/widgets/file-upload-single.js
+++ b/web_interface/static/v3/js/widgets/file-upload-single.js
@@ -126,6 +126,7 @@
html += ``;
html += '';
+ // eslint-disable-next-line no-unsanitized/property -- all dynamic values sanitized by escapeHtml()
container.innerHTML = html;
},
@@ -216,10 +217,14 @@
return;
}
- // Show uploading status
+ // Show uploading status — use DOM methods to avoid innerHTML with dynamic data
if (statusDiv) {
statusDiv.className = 'mt-1 text-xs text-gray-500';
- statusDiv.innerHTML = 'Uploading...';
+ statusDiv.textContent = '';
+ const spinner = document.createElement('i');
+ spinner.className = 'fas fa-spinner fa-spin mr-1';
+ statusDiv.appendChild(spinner);
+ statusDiv.appendChild(document.createTextNode('Uploading…'));
}
const formData = new FormData();
@@ -247,8 +252,12 @@
if (statusDiv) {
statusDiv.className = 'mt-1 text-xs text-green-600';
- statusDiv.innerHTML = 'Uploaded successfully';
- setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.innerHTML = ''; }, 3000);
+ statusDiv.textContent = '';
+ const icon = document.createElement('i');
+ icon.className = 'fas fa-check-circle mr-1';
+ statusDiv.appendChild(icon);
+ statusDiv.appendChild(document.createTextNode('Uploaded successfully'));
+ setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.textContent = ''; }, 3000);
}
notifyFn('Image uploaded successfully', 'success');
} else {
@@ -257,7 +266,11 @@
} catch (error) {
if (statusDiv) {
statusDiv.className = 'mt-1 text-xs text-red-600';
- statusDiv.innerHTML = `${escapeHtml(error.message)}`;
+ statusDiv.textContent = '';
+ const errIcon = document.createElement('i');
+ errIcon.className = 'fas fa-exclamation-circle mr-1';
+ statusDiv.appendChild(errIcon);
+ statusDiv.appendChild(document.createTextNode(error.message || 'Upload failed'));
}
notifyFn(`Upload error: ${error.message}`, 'error');
} finally {
diff --git a/web_interface/static/v3/js/widgets/plugin-file-manager.js b/web_interface/static/v3/js/widgets/plugin-file-manager.js
index e757bac6..6200f303 100644
--- a/web_interface/static/v3/js/widgets/plugin-file-manager.js
+++ b/web_interface/static/v3/js/widgets/plugin-file-manager.js
@@ -261,6 +261,8 @@
grid.addEventListener('click', st._gridClickHandler);
grid.addEventListener('change', st._gridChangeHandler);
+ // All dynamic values in the template literal are sanitized by escHtml().
+ // eslint-disable-next-line no-unsanitized/property -- values sanitized by escHtml()
grid.innerHTML = st.files.map(f => `
@@ -305,6 +307,7 @@
// Build modal using DOM methods so filename never enters a JS string literal.
const modal = document.createElement('div');
modal.className = 'pfm-modal';
+ // eslint-disable-next-line no-unsanitized/property -- filename sanitized by escHtml()
modal.innerHTML = `