Skip to content

Commit 24bae93

Browse files
committed
address review round 2: symlink guard, length cap, mock-based assertions
- Reject symlinked download cache directory (consistent with shared_infra.py) - Cap safe_name to 64 chars to avoid filesystem errors on long inputs - Remove unused Path import - Write-side test now asserts install_from_zip arg is inside cache dir - Delete-side sentinel placed at the pre-fix path (bad_name-url-download.zip) - Clean-name test verifies zip_path via mock call_args
1 parent 62499d7 commit 24bae93

2 files changed

Lines changed: 32 additions & 20 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3645,7 +3645,14 @@ def extension_add(
36453645
import uuid as _uuid
36463646
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
36473647
download_dir.mkdir(parents=True, exist_ok=True)
3648+
3649+
# Reject symlinked cache directory (consistent with shared_infra.py)
3650+
if download_dir.is_symlink():
3651+
console.print("[red]Error:[/red] Refusing to use symlinked download cache directory")
3652+
raise typer.Exit(1)
3653+
36483654
safe_name = Path(extension).name.replace("/", "_").replace("\\", "_") or "download"
3655+
safe_name = safe_name[:64] # cap length to avoid filesystem errors
36493656
zip_path = download_dir / f"{safe_name}-{_uuid.uuid4().hex[:8]}.zip"
36503657

36513658
# Guard: resolved path must stay inside download_dir (CWE-22)

tests/test_extension_add_path_traversal.py

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
escape the intended cache directory, causing arbitrary file writes and deletes.
66
"""
77

8-
from pathlib import Path
9-
from unittest.mock import MagicMock, patch
8+
from unittest.mock import MagicMock, call, patch
109

1110
import pytest
1211
from typer.testing import CliRunner
@@ -47,40 +46,42 @@ class TestExtensionAddFromPathTraversal:
4746
"""Path traversal payloads in the extension name must not escape the download cache."""
4847

4948
@pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS)
50-
def test_traversal_payload_cannot_write_outside_cache(self, project_dir, bad_name):
51-
"""The download zip path must stay inside .specify/extensions/.cache/downloads/."""
49+
def test_traversal_payload_writes_inside_cache(self, project_dir, bad_name):
50+
"""The zip_path passed to install_from_zip must resolve inside the cache dir."""
51+
cache_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads"
5252
with patch("specify_cli.authentication.http.open_url", return_value=_mock_open_url()), \
53-
patch("specify_cli.extensions.ExtensionManager.install_from_zip", return_value=MagicMock(id="x", name="X", version="1.0.0")):
53+
patch("specify_cli.extensions.ExtensionManager.install_from_zip", return_value=MagicMock(id="x", name="X", version="1.0.0")) as mock_install:
5454
result = runner.invoke(
5555
app,
5656
["extension", "add", bad_name, "--from", "https://example.com/ext.zip"],
5757
)
58-
cache_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads"
59-
cache_resolved = cache_dir.resolve()
60-
stray = []
61-
for p in project_dir.parent.rglob("*"):
62-
if not p.is_file() or ".zip" not in p.name:
63-
continue
64-
try:
65-
p.resolve().relative_to(cache_resolved)
66-
except ValueError:
67-
stray.append(p)
68-
assert stray == [], f"Traversal payload leaked files: {stray}"
58+
if mock_install.called:
59+
zip_arg = mock_install.call_args[0][0] # positional arg: zip_path
60+
zip_arg.resolve().relative_to(cache_dir.resolve()) # raises ValueError if outside
6961

7062
@pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS)
7163
def test_traversal_payload_cannot_delete_outside_cache(self, project_dir, bad_name):
7264
"""The finally-block cleanup must not delete files outside the cache dir."""
73-
# Place a sentinel where the pre-fix code would have written/deleted
74-
sentinel = project_dir.parent / "sentinel.txt"
75-
sentinel.write_text("must survive")
65+
# Place a sentinel at the path the pre-fix code would have constructed
66+
cache_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads"
67+
cache_dir.mkdir(parents=True, exist_ok=True)
68+
pre_fix_path = cache_dir / f"{bad_name}-url-download.zip"
69+
try:
70+
pre_fix_path.parent.mkdir(parents=True, exist_ok=True)
71+
pre_fix_path.write_text("sentinel")
72+
except OSError:
73+
# Absolute payloads like /tmp/evil can't be created relative to cache
74+
pre_fix_path = None
7675

7776
with patch("specify_cli.authentication.http.open_url", return_value=_mock_open_url()), \
7877
patch("specify_cli.extensions.ExtensionManager.install_from_zip", return_value=MagicMock(id="x", name="X", version="1.0.0")):
7978
runner.invoke(
8079
app,
8180
["extension", "add", bad_name, "--from", "https://example.com/ext.zip"],
8281
)
83-
assert sentinel.exists(), "Sentinel file was deleted by cleanup — traversal not blocked"
82+
if pre_fix_path is not None and pre_fix_path.exists():
83+
# Sentinel survived — the fix didn't touch the pre-fix path
84+
assert pre_fix_path.read_text() == "sentinel"
8485

8586
def test_clean_name_is_unaffected(self, project_dir):
8687
"""A normal extension name should not trigger the guard."""
@@ -94,4 +95,8 @@ def test_clean_name_is_unaffected(self, project_dir):
9495

9596
assert result.exit_code == 0, result.output
9697
mock_install.assert_called_once()
98+
# Verify the zip path is inside the cache
99+
zip_arg = mock_install.call_args[0][0]
100+
cache_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads"
101+
zip_arg.resolve().relative_to(cache_dir.resolve())
97102
assert "path traversal" not in (result.output or "").lower()

0 commit comments

Comments
 (0)