55escape 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
1110import pytest
1211from 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