mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 09:18:06 +00:00
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:
@@ -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}",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user