diff --git a/docs/widget-guide.md b/docs/widget-guide.md index be310fb4..dadda4cf 100644 --- a/docs/widget-guide.md +++ b/docs/widget-guide.md @@ -47,7 +47,7 @@ Full inline file management UI for plugins that manage files via the `web_ui_act } ``` -Not all 7 actions are required — omit any key to hide the corresponding UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch). +**`list` is required** — the widget calls it on render to populate the file grid; omitting it leaves the widget stuck in a loading state. All other actions are optional — omit any key to hide its UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch). The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea. diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index e3d0976e..f4c0ba68 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -5,6 +5,10 @@ import logging import os import re from pathlib import Path + +# Strict allowlists for URL-derived values used in path and script operations. +_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$') +_SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$') from src.web_interface.secret_helpers import mask_secret_fields logger = logging.getLogger(__name__) @@ -110,23 +114,35 @@ def serve_plugin_web_ui(plugin_id, filename): Wraps the fragment with a minimal HTML page that injects window.PLUGIN_ID and loads Tailwind CSS so the fragment runs correctly in a sandboxed iframe. """ + # Validate URL-derived values against strict allowlists before any path or + # script operations. This satisfies CodeQL py/path-injection and + # py/reflected-xss by ensuring neither value contains path separators, + # HTML/JS special chars, or other unexpected content. + if not _SAFE_PLUGIN_ID_RE.match(plugin_id): + return 'Invalid plugin ID', 400, {'Content-Type': 'text/plain'} + if not _SAFE_WEB_UI_FILE_RE.match(filename): + return 'Invalid filename', 400, {'Content-Type': 'text/plain'} + try: _plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve() _plugin_dir = (_plugins_base / plugin_id).resolve() - # Path traversal guard — plugin_dir must be inside plugins base + # Path containment guard — plugin_dir must be inside plugins base _plugin_dir.relative_to(_plugins_base) web_ui_path = (_plugin_dir / 'web_ui' / filename).resolve() - # Second guard — web_ui_path must stay inside web_ui/ + # Second containment guard — must stay inside the plugin's web_ui dir web_ui_path.relative_to(_plugin_dir / 'web_ui') if not web_ui_path.exists(): - return f'web_ui file not found: {filename}', 404 - if web_ui_path.suffix.lower() != '.html': - return 'Only .html files may be served here', 403 + return 'Not found', 404, {'Content-Type': 'text/plain'} fragment = web_ui_path.read_text(encoding='utf-8') + # json.dumps produces a safe JSON string, but inside it could + # break the enclosing script tag. Re-encode those bytes as Unicode + # escapes so the value is inert in an HTML context. + safe_plugin_id_js = json.dumps(plugin_id).replace('<', r'<').replace('>', r'>').replace('&', r'&') + page = ( '\n' '\n' @@ -134,8 +150,10 @@ def serve_plugin_web_ui(plugin_id, filename): '\n' '\n' '\n' # Tailwind v2 CDN — same version used by the parent LEDMatrix UI ' `
@@ -246,7 +268,8 @@ ${st.actions.toggle ? ` ` : ''}
@@ -260,12 +283,14 @@
${st.actions.get && st.actions.save ? ` ` : ''} ${st.actions.delete ? ` ` : ''}
@@ -277,27 +302,30 @@ window._pfmOpenEdit = async function (fieldId, filename) { const st = getState(fieldId); const overlay = createOverlay(fieldId); - overlay.innerHTML = ` -
-
- ${escHtml(filename)} - -
-
-
Loading…
-
- + // Build modal using DOM methods so filename never enters a JS string literal. + const modal = document.createElement('div'); + modal.className = 'pfm-modal'; + modal.innerHTML = ` +
+ ${escHtml(filename)} + +
+
+
Loading…
+
+ `; + overlay.appendChild(modal); + // Bind events after DOM insertion — filename captured in closure, not in HTML. + modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId)); + modal.querySelector(`#${CSS.escape(fieldId)}_modal_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId)); + modal.querySelector(`#${CSS.escape(fieldId)}_save_btn`).addEventListener('click', () => window._pfmSave(fieldId, filename)); const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null); const body = document.getElementById(`${fieldId}_edit_body`); @@ -307,18 +335,20 @@ } const content = data.content || data.data || {}; - st._editData = content; st._editFilename = filename; if (isTabular(content)) { + // Table path: track cell edits live in _editData + st._editData = content; renderEntryTable(fieldId, body, content); } else { - // Fallback: JSON textarea + // Textarea path: _editData stays null; save() reads from the -
`; +
`; } }; @@ -401,14 +431,23 @@ st._tableCols = cols; } + // Store buildPage in per-instance state so multiple instances don't + // clobber each other's pagination via a shared global. + st._buildPage = buildPage; buildPage(st._tablePage || 1); - window._pfmTablePage = function (fId, p) { - const s = getState(fId); - const totalP = Math.ceil(s._tableEntries.length / s.entriesPerPage); - buildPage(Math.max(1, Math.min(p, totalP))); - }; } + // Global dispatcher — resolves the per-instance buildPage from state so + // multiple plugin-file-manager instances don't clobber each other. + window._pfmTablePage = function (fId, p) { + const s = getState(fId); + if (s._buildPage) { + const total = s._tableEntries ? s._tableEntries.length : 0; + const totalP = Math.ceil(total / s.entriesPerPage) || 1; + s._buildPage(Math.max(1, Math.min(p, totalP))); + } + }; + window._pfmCellEdit = function (fieldId, day, col, value) { const st = getState(fieldId); if (st._editData && st._editData[day]) st._editData[day][col] = value; @@ -454,30 +493,32 @@ window._pfmOpenDelete = function (fieldId, filename) { const overlay = createOverlay(fieldId); - overlay.innerHTML = ` -
-
- Delete File - -
-
-
- ${escHtml(filename)} will be permanently deleted and removed - from the plugin configuration. This cannot be undone. -
-
-