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
+32 -25
View File
@@ -207,7 +207,7 @@ class PluginLoader:
self, self,
plugin_dir: Path, plugin_dir: Path,
plugin_id: str, plugin_id: str,
plugins_dir: Optional[Path] = None, plugins_dir: Path,
timeout: int = 300 timeout: int = 300
) -> bool: ) -> bool:
""" """
@@ -216,7 +216,12 @@ class PluginLoader:
Args: Args:
plugin_dir: Plugin directory path plugin_dir: Plugin directory path
plugin_id: Plugin identifier plugin_id: Plugin identifier
plugins_dir: Trusted base plugins directory for path containment check plugins_dir: Trusted base plugins directory for path containment check.
Required (not optional) so every caller reconstructs the plugin
path through the sanitiser below rather than trusting plugin_dir
directly -- CodeQL's path-injection query (and a malicious
manifest/plugin_id in practice) can't tell a legitimate
plugin_dir from one crafted to traverse outside plugins_dir.
timeout: Installation timeout in seconds timeout: Installation timeout in seconds
Returns: Returns:
@@ -229,29 +234,23 @@ class PluginLoader:
# Resolve to a canonical absolute path (normalises .. and symlinks) # Resolve to a canonical absolute path (normalises .. and symlinks)
plugin_dir_real = os.path.realpath(str(plugin_dir)) plugin_dir_real = os.path.realpath(str(plugin_dir))
if plugins_dir is not None: # Reconstruct the plugin path from a trusted base + a sanitised
# Reconstruct the plugin path from a trusted base + a sanitised # directory name. os.path.basename() is CodeQL's recognised
# directory name. os.path.basename() is CodeQL's recognised # py/path-injection sanitiser: it strips all directory components
# py/path-injection sanitiser: it strips all directory components # so the result cannot contain traversal sequences. Joining it
# so the result cannot contain traversal sequences. Joining it # with the resolved, trusted plugins_dir produces a path that
# with the resolved, trusted plugins_dir produces a path that # CodeQL considers untainted.
# CodeQL considers untainted. plugins_dir_real = os.path.realpath(str(plugins_dir))
plugins_dir_real = os.path.realpath(str(plugins_dir)) safe_dir_name = os.path.basename(plugin_dir_real)
safe_dir_name = os.path.basename(plugin_dir_real) if not safe_dir_name:
if not safe_dir_name: self.logger.error("Could not determine plugin directory name for %s", plugin_id)
self.logger.error("Could not determine plugin directory name for %s", plugin_id) return False
return False safe_plugin_dir = os.path.join(plugins_dir_real, safe_dir_name)
safe_plugin_dir = os.path.join(plugins_dir_real, safe_dir_name) if not os.path.isdir(safe_plugin_dir):
if not os.path.isdir(safe_plugin_dir): self.logger.error(
self.logger.error( "Plugin directory for %s not found inside plugins dir", plugin_id
"Plugin directory for %s not found inside plugins dir", plugin_id )
) return False
return False
else:
safe_plugin_dir = plugin_dir_real
if not os.path.isdir(safe_plugin_dir):
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
return False
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt") requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
@@ -698,6 +697,14 @@ class PluginLoader:
""" """
# Install dependencies if needed # Install dependencies if needed
if install_deps: if install_deps:
if plugins_dir is None:
raise PluginError(
f"plugins_dir is required to install dependencies for plugin {plugin_id} "
"(needed for path containment; pass install_deps=False if the caller "
"doesn't have a trusted plugins directory to supply)",
plugin_id=plugin_id,
context={'plugin_dir': str(plugin_dir)},
)
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir): if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
raise PluginError( raise PluginError(
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}", f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
+24 -4
View File
@@ -1900,15 +1900,35 @@ class PluginStoreManager:
def _install_dependencies(self, plugin_path: Path) -> bool: def _install_dependencies(self, plugin_path: Path) -> bool:
""" """
Install Python dependencies from requirements.txt. Install Python dependencies from requirements.txt.
Args: Args:
plugin_path: Path to plugin directory plugin_path: Path to plugin directory
Returns: Returns:
True if successful or no requirements file 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(): if not requirements_file.exists():
self.logger.debug(f"No requirements.txt found in {plugin_path.name}") self.logger.debug(f"No requirements.txt found in {plugin_path.name}")
return True return True
+34 -7
View File
@@ -193,7 +193,7 @@ class TestPluginLoader:
mock_subprocess.return_value = MagicMock(returncode=0) mock_subprocess.return_value = MagicMock(returncode=0)
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin") result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True assert result is True
mock_subprocess.assert_called_once() mock_subprocess.assert_called_once()
@@ -204,7 +204,7 @@ class TestPluginLoader:
plugin_dir = tmp_plugins_dir / "test_plugin" plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir() plugin_dir.mkdir()
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin") result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True assert result is True
mock_subprocess.assert_not_called() mock_subprocess.assert_not_called()
@@ -219,7 +219,7 @@ class TestPluginLoader:
mock_subprocess.return_value = MagicMock(returncode=1) mock_subprocess.return_value = MagicMock(returncode=1)
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin") result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is False assert result is False
@@ -245,7 +245,7 @@ class TestPluginLoader:
retry_attempt = MagicMock(returncode=0, stderr="") retry_attempt = MagicMock(returncode=0, stderr="")
mock_subprocess.side_effect = [first_attempt, retry_attempt] mock_subprocess.side_effect = [first_attempt, retry_attempt]
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin") result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True assert result is True
assert mock_subprocess.call_count == 2 assert mock_subprocess.call_count == 2
@@ -271,7 +271,7 @@ class TestPluginLoader:
retry_attempt = MagicMock(returncode=1, stderr="some other pip error") retry_attempt = MagicMock(returncode=1, stderr="some other pip error")
mock_subprocess.side_effect = [first_attempt, retry_attempt] mock_subprocess.side_effect = [first_attempt, retry_attempt]
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin") result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True assert result is True
assert mock_subprocess.call_count == 2 assert mock_subprocess.call_count == 2
@@ -298,7 +298,7 @@ class TestPluginLoader:
subprocess.TimeoutExpired(cmd="pip", timeout=300), subprocess.TimeoutExpired(cmd="pip", timeout=300),
] ]
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin") result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True assert result is True
assert mock_subprocess.call_count == 2 assert mock_subprocess.call_count == 2
@@ -311,7 +311,34 @@ class TestPluginLoader:
requirements_file = plugin_dir / "requirements.txt" requirements_file = plugin_dir / "requirements.txt"
requirements_file.write_text("pytest>=1.0\n") requirements_file.write_text("pytest>=1.0\n")
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin") result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True assert result is True
mock_subprocess.assert_not_called() mock_subprocess.assert_not_called()
def test_install_dependencies_requires_plugins_dir(self, plugin_loader, tmp_plugins_dir):
"""plugins_dir is a required argument, not an optional trust-me flag --
calling without it must fail loudly (TypeError) rather than silently
falling back to trusting plugin_dir unchecked."""
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
with pytest.raises(TypeError):
plugin_loader.install_dependencies(plugin_dir, "test_plugin")
@patch('subprocess.run')
def test_install_dependencies_rejects_path_outside_plugins_dir(
self, mock_subprocess, plugin_loader, tmp_path, tmp_plugins_dir
):
"""A plugin_dir that doesn't actually live inside plugins_dir (e.g. a
manifest-derived id crafted to traverse elsewhere) must be rejected
rather than read from -- this is the path-injection containment
check CodeQL flagged as missing."""
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
(outside_dir / "requirements.txt").write_text("requests>=2.0\n")
result = plugin_loader.install_dependencies(outside_dir, "evil_plugin", plugins_dir=tmp_plugins_dir)
assert result is False
mock_subprocess.assert_not_called()