fix(security): close path-injection gap in dependency-satisfaction checks

CodeQL flagged 2 new high-severity "uncontrolled data used in path
expression" alerts at the open() calls inside this PR's new
requirements_has_real_deps()/requirements_are_satisfied() -- both are
reachable from paths that were never run through the basename+trusted-base
sanitiser this codebase already uses elsewhere:

- PluginLoader.install_dependencies() only applied that sanitiser when its
  optional plugins_dir argument was actually passed; the "no plugins_dir"
  branch trusted plugin_dir_real directly. Made plugins_dir required (not
  Optional) so that branch can't exist, and added an explicit guard in
  load_plugin() so install_deps=True without a plugins_dir fails loudly
  instead of silently. Production's only real caller (PluginManager) always
  passes plugins_dir already; the harness/dev-server/render-plugin callers
  all use install_deps=False and are unaffected.

- StoreManager._install_dependencies() never sanitised plugin_path at all,
  and its call sites ultimately derive that path from a plugin's own
  manifest.json "id" field (install_plugin_from_url) -- a malicious plugin
  could otherwise point requirements_file outside plugins_dir. Applied the
  same os.path.basename()-based containment pattern PluginLoader already
  uses (and that CodeQL recognises as a real sanitiser).

Added test_install_dependencies_requires_plugins_dir and
test_install_dependencies_rejects_path_outside_plugins_dir to lock in the
actual security property, not just quiet the scanner. Verified: all 20
tests in test_plugin_loader.py pass, plus the PR's existing test plan
(test_plugin_system.py, test_store_manager_caches.py: 53 passed) and the
full CI plugin-safety suite (test_harness.py, test_visual_rendering.py,
test_plugin_matrix.py: 52 passed, 2 pre-existing skips) all still pass.
This commit is contained in:
ChuckBuilds
2026-07-10 15:10:49 -04:00
parent d86dc5914b
commit d59a66133b
3 changed files with 90 additions and 36 deletions
+24 -4
View File
@@ -1900,15 +1900,35 @@ class PluginStoreManager:
def _install_dependencies(self, plugin_path: Path) -> bool:
"""
Install Python dependencies from requirements.txt.
Args:
plugin_path: Path to plugin directory
Returns:
True if successful or no requirements file
"""
requirements_file = plugin_path / "requirements.txt"
# Reconstruct the plugin path from the trusted self.plugins_dir base +
# a sanitised directory name rather than trusting plugin_path directly
# -- callers ultimately derive it from a plugin-supplied manifest "id"
# field (see install_plugin_from_url), so without this a malicious
# manifest could point requirements_file outside plugins_dir.
# os.path.basename() is CodeQL's recognised py/path-injection
# sanitiser: it strips all directory components so the result cannot
# contain traversal sequences, matching the pattern already used in
# PluginLoader.install_dependencies().
plugin_dir_real = os.path.realpath(str(plugin_path))
plugins_dir_real = os.path.realpath(str(self.plugins_dir))
safe_dir_name = os.path.basename(plugin_dir_real)
if not safe_dir_name:
self.logger.error("Could not determine plugin directory name for dependency install")
return False
safe_plugin_path = Path(os.path.join(plugins_dir_real, safe_dir_name))
if not safe_plugin_path.is_dir():
self.logger.error("Plugin directory not found inside plugins dir: %s", safe_plugin_path)
return False
requirements_file = safe_plugin_path / "requirements.txt"
if not requirements_file.exists():
self.logger.debug(f"No requirements.txt found in {plugin_path.name}")
return True