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 ' `