Skip to content

Commit 19dfad7

Browse files
committed
Prevent extension config and download path escapes
Extension catalog commands accepted parsed YAML roots without first checking that they were mappings, so corrupted configs could surface raw AttributeError tracebacks. URL-based extension downloads also derived their cache filename from user input, which let path separators escape the intended cache directory. The command layer now validates catalog config roots before calling get(), and URL downloads use an unnamed temporary filename created inside the downloads cache directory. Regression tests cover both corrupted catalog roots and traversal-looking extension names.
1 parent 2d13763 commit 19dfad7

3 files changed

Lines changed: 113 additions & 12 deletions

File tree

src/specify_cli/extensions/_commands.py

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import os
1212
import shutil
13+
import tempfile
1314
import zipfile
1415
from pathlib import Path
1516
from typing import Optional
@@ -58,6 +59,27 @@ def _display_project_path(*args, **kwargs):
5859
return _f(*args, **kwargs)
5960

6061

62+
def _load_catalog_command_config(project_root: Path, config_path: Path) -> dict:
63+
"""Load extension catalog CLI config with user-facing shape errors."""
64+
try:
65+
config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
66+
except Exception as e:
67+
config_label = _display_project_path(project_root, config_path)
68+
console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}")
69+
raise typer.Exit(1)
70+
71+
if config is None:
72+
return {}
73+
if not isinstance(config, dict):
74+
config_label = _display_project_path(project_root, config_path)
75+
console.print(
76+
f"[red]Error:[/red] Invalid catalog config {config_label}: "
77+
"expected a YAML mapping at the root."
78+
)
79+
raise typer.Exit(1)
80+
return config
81+
82+
6183
def _resolve_installed_extension(
6284
argument: str,
6385
installed_extensions: list,
@@ -293,12 +315,7 @@ def catalog_add(
293315

294316
# Load existing config
295317
if config_path.exists():
296-
try:
297-
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
298-
except Exception as e:
299-
config_label = _display_project_path(project_root, config_path)
300-
console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}")
301-
raise typer.Exit(1)
318+
config = _load_catalog_command_config(project_root, config_path)
302319
else:
303320
config = {}
304321

@@ -345,11 +362,7 @@ def catalog_remove(
345362
console.print("[red]Error:[/red] No catalog config found. Nothing to remove.")
346363
raise typer.Exit(1)
347364

348-
try:
349-
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
350-
except Exception:
351-
console.print("[red]Error:[/red] Failed to read catalog config.")
352-
raise typer.Exit(1)
365+
config = _load_catalog_command_config(project_root, config_path)
353366

354367
catalogs = config.get("catalogs", [])
355368
if not isinstance(catalogs, list):
@@ -463,7 +476,13 @@ def extension_add(
463476
# Download ZIP to temp location
464477
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
465478
download_dir.mkdir(parents=True, exist_ok=True)
466-
zip_path = download_dir / f"{extension}-url-download.zip"
479+
with tempfile.NamedTemporaryFile(
480+
prefix="extension-url-download-",
481+
suffix=".zip",
482+
dir=download_dir,
483+
delete=False,
484+
) as download_file:
485+
zip_path = Path(download_file.name)
467486

468487
try:
469488
from specify_cli.authentication.http import open_url as _open_url

tests/integrations/test_cli.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1315,6 +1315,36 @@ def test_catalog_config_output_uses_posix_paths(self, tmp_path):
13151315
assert extension_list.exit_code == 0, extension_list.output
13161316
assert "Config: .specify/extension-catalogs.yml" in extension_list.output
13171317

1318+
def test_extension_catalog_add_rejects_non_mapping_config_root(self, tmp_path):
1319+
project = self._make_project(tmp_path)
1320+
cfg_path = project / ".specify" / "extension-catalogs.yml"
1321+
cfg_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8")
1322+
1323+
result = self._invoke([
1324+
"extension", "catalog", "add",
1325+
"https://example.com/extension-catalog.yml",
1326+
"--name", "demo-extensions",
1327+
], project)
1328+
1329+
assert result.exit_code == 1, result.output
1330+
output = _normalize_cli_output(result.output)
1331+
assert "Invalid catalog config .specify/extension-catalogs.yml" in output
1332+
assert "expected a YAML mapping at the root" in output
1333+
assert "AttributeError" not in output
1334+
1335+
def test_extension_catalog_remove_rejects_non_mapping_config_root(self, tmp_path):
1336+
project = self._make_project(tmp_path)
1337+
cfg_path = project / ".specify" / "extension-catalogs.yml"
1338+
cfg_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8")
1339+
1340+
result = self._invoke(["extension", "catalog", "remove", "demo"], project)
1341+
1342+
assert result.exit_code == 1, result.output
1343+
output = _normalize_cli_output(result.output)
1344+
assert "Invalid catalog config .specify/extension-catalogs.yml" in output
1345+
assert "expected a YAML mapping at the root" in output
1346+
assert "AttributeError" not in output
1347+
13181348
# -- search ------------------------------------------------------------
13191349

13201350
def test_search_lists_all(self, tmp_path, monkeypatch):

tests/test_extensions.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4871,6 +4871,58 @@ def test_add_from_url_cancel_exits_cleanly(self, tmp_path):
48714871
assert result.exit_code == 0
48724872
assert "Cancelled" in result.output
48734873

4874+
def test_add_from_url_uses_cache_tempfile_for_untrusted_extension_name(self, tmp_path):
4875+
"""The extension argument must not control the downloaded ZIP path."""
4876+
import io
4877+
from types import SimpleNamespace
4878+
from typer.testing import CliRunner
4879+
from unittest.mock import patch
4880+
from specify_cli import app
4881+
4882+
class FakeResponse(io.BytesIO):
4883+
def __enter__(self):
4884+
return self
4885+
4886+
def __exit__(self, exc_type, exc, tb):
4887+
return False
4888+
4889+
project_dir = tmp_path / "test-project"
4890+
project_dir.mkdir()
4891+
(project_dir / ".specify").mkdir()
4892+
downloads_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads"
4893+
installed = {}
4894+
4895+
def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, force=False):
4896+
captured_path = Path(zip_path)
4897+
installed["zip_path"] = captured_path
4898+
installed["zip_bytes"] = captured_path.read_bytes()
4899+
return SimpleNamespace(
4900+
id="escape",
4901+
name="Escape Test",
4902+
version="1.0.0",
4903+
description="Test extension",
4904+
warnings=[],
4905+
commands=[],
4906+
hooks=[],
4907+
)
4908+
4909+
runner = CliRunner()
4910+
with patch.object(Path, "cwd", return_value=project_dir), \
4911+
patch("typer.confirm", return_value=True), \
4912+
patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(b"zip-bytes")), \
4913+
patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip):
4914+
result = runner.invoke(
4915+
app,
4916+
["extension", "add", "../outside", "--from", "https://example.com/ext.zip"],
4917+
catch_exceptions=True,
4918+
)
4919+
4920+
assert result.exit_code == 0
4921+
assert installed["zip_bytes"] == b"zip-bytes"
4922+
assert installed["zip_path"].resolve().is_relative_to(downloads_dir.resolve())
4923+
assert installed["zip_path"].name.startswith("extension-url-download-")
4924+
assert not installed["zip_path"].exists()
4925+
48744926

48754927
class TestDownloadExtensionBundled:
48764928
"""Tests for download_extension handling of bundled extensions."""

0 commit comments

Comments
 (0)