diff --git a/CHANGELOG.md b/CHANGELOG.md
index ea497352..43354296 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -112,6 +112,26 @@ Web interface:
re-sent with backoff instead of being counted as failed and skipped — that is
how a disabled plugin with an update waiting was silently left out.
+Security (request paths and inline handlers, siblings of #561):
+
+- `POST /api/v3/plugins/assets/upload`, `GET .../assets/list` and
+ `POST .../assets/delete` validate `plugin_id` with `src/common/path_safety`
+ and answer 400 otherwise. A `plugin_id` of `../../config` used to create an
+ `uploads/` directory outside `assets/plugins`, write images and
+ `.metadata.json` there, list it, and delete whatever file a metadata entry
+ named. Delete now unlinks only a path that resolves inside that plugin's
+ uploads directory (any other entry is dropped without touching a file).
+- `PluginManager.get_plugin_directory()` returns `None` for anything but a
+ plain name, so `POST /api/v3/plugins/action` can no longer run a manifest
+ script from a directory outside the plugins directory (`../elsewhere`); the
+ route also rejects such ids with 400.
+- Plugin Store, saved-repository and custom-registry buttons escape registry
+ values for their inline `onclick` handlers (`jsStringAttr` in
+ `plugins_manager.js`). An entry id containing `'` used to close the attribute
+ and add its own script. The store's View button opens only `http(s)` links.
+- The uploaded-images list escapes each file's original name, path and ids; a
+ name like `.png` was inserted as markup.
+
Plugin system:
- A plugin no longer starts with a schema warning and a degraded flag because
diff --git a/src/plugin_system/plugin_manager.py b/src/plugin_system/plugin_manager.py
index c587acc8..a79ec867 100644
--- a/src/plugin_system/plugin_manager.py
+++ b/src/plugin_system/plugin_manager.py
@@ -23,6 +23,7 @@ from src.plugin_system.plugin_loader import PluginLoader
from src.plugin_system.plugin_executor import PluginExecutor
from src.plugin_system.plugin_state import PluginStateManager, PluginState
from src.plugin_system.schema_manager import SchemaManager, normalize_legacy_booleans
+from src.common.path_safety import safe_path_component
from src.common.permission_utils import (
ensure_directory_permissions,
get_plugin_dir_mode
@@ -743,11 +744,20 @@ class PluginManager:
Returns:
Directory path as string or None if not found
+
+ ``plugin_id`` often comes straight from a request, so anything that is
+ not one plain path segment (``..``, ``a/b``, an absolute path) is
+ refused instead of being joined onto ``plugins_dir``. The join is not
+ resolved further: dev plugins are symlinks into ``plugins_dir``.
"""
with self._discovery_lock:
if hasattr(self, 'plugin_directories') and plugin_id in self.plugin_directories:
return str(self.plugin_directories[plugin_id])
-
+
+ plugin_id = safe_path_component(plugin_id)
+ if plugin_id is None:
+ return None
+
plugin_dir = self.plugins_dir / plugin_id
if plugin_dir.exists():
return str(plugin_dir)
diff --git a/test/js/README.md b/test/js/README.md
index 9d7c3b95..c9eae3b9 100644
--- a/test/js/README.md
+++ b/test/js/README.md
@@ -40,6 +40,7 @@ nothing is listening, so it stays useful in a bare checkout.
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
+| `unit/test_inline_handler_escaping.js` | no | The store, saved-repository and custom-registry inline `onclick` handlers and the live `window.updateImageList` from `plugins_manager.js`: a registry id, URL or uploaded file name carrying `'`, `"` or entities adds no attributes and reaches the handler intact, and the store's View button opens only http(s) links |
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
diff --git a/test/js/run_all.js b/test/js/run_all.js
index bf9ca625..abf0fff7 100755
--- a/test/js/run_all.js
+++ b/test/js/run_all.js
@@ -18,7 +18,7 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
'unit/test_style_editor_layout_leaf_columns.js',
'unit/test_style_editor_layout_leaf_collision.js',
- 'unit/test_update_all.js'];
+ 'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js'];
diff --git a/test/js/unit/test_inline_handler_escaping.js b/test/js/unit/test_inline_handler_escaping.js
new file mode 100644
index 00000000..984d4299
--- /dev/null
+++ b/test/js/unit/test_inline_handler_escaping.js
@@ -0,0 +1,215 @@
+// Registry- and upload-supplied strings must not escape the inline handlers
+// plugins_manager.js builds for them.
+//
+// The store, saved-repository and custom-registry renderers wrote
+//
+//