diff --git a/scripts/dev_server.py b/scripts/dev_server.py index 410d0ea6..60976ae8 100644 --- a/scripts/dev_server.py +++ b/scripts/dev_server.py @@ -256,24 +256,36 @@ def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, heig } +def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]: + """Re-derive a plugin directory from the search dirs' own listings. + + Path-injection barrier: the returned Path is constructed purely from + trusted directory enumeration (``iterdir``) — request-derived strings + never enter its construction — so a crafted plugin id can never make + downstream file access leave the plugin search dirs. Comparison is by + path equality, deliberately without symlink resolution (dev plugins + are commonly symlinked into plugins/). + """ + wanted = Path(os.path.normpath(str(plugin_dir))) + for search_dir in get_search_dirs(): + if not search_dir.is_dir(): + continue + for entry in search_dir.iterdir(): + if entry.is_dir() and entry == wanted: + return entry + return None + + def _parse_render_request(data): """Shared /api/render* request prep. Returns (plugin_dir, manifest, config, mock_data, skip_update) or raises ValueError with a client message.""" plugin_id = data['plugin_id'] plugin_dir = find_plugin_dir(plugin_id) + if plugin_dir: + plugin_dir = _trusted_plugin_dir(plugin_dir) if not plugin_dir: raise LookupError(f'Plugin not found: {plugin_id}') - # Path-injection barrier: normalize (no symlink resolution — dev plugins - # are commonly symlinked into plugins/) and require the directory to sit - # inside one of the plugin search dirs before any file access. - normalized = os.path.normpath(str(plugin_dir)) - allowed_roots = [os.path.normpath(str(d)) for d in get_search_dirs()] - if not any(normalized == root or normalized.startswith(root + os.sep) - for root in allowed_roots): - raise LookupError(f'Plugin not found: {plugin_id}') - plugin_dir = Path(normalized) - manifest_path = plugin_dir / 'manifest.json' with open(manifest_path, 'r') as f: manifest = json.load(f)