Skip to content

Commit 80cea01

Browse files
committed
security(init): validate zip member paths before extraction
1 parent 9bec94d commit 80cea01

2 files changed

Lines changed: 49 additions & 0 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,19 @@ def download_and_extract_template(
10071007
project_path.mkdir(parents=True)
10081008

10091009
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
1010+
def _validate_zip_members_within(root: Path) -> None:
1011+
"""Validate all ZIP members stay within ``root`` (Zip Slip guard)."""
1012+
root_resolved = root.resolve()
1013+
for member in zip_ref.namelist():
1014+
member_path = (root / member).resolve()
1015+
try:
1016+
member_path.relative_to(root_resolved)
1017+
except ValueError:
1018+
raise RuntimeError(
1019+
f"Unsafe path in ZIP archive: {member} "
1020+
"(potential path traversal)"
1021+
)
1022+
10101023
zip_contents = zip_ref.namelist()
10111024
if tracker:
10121025
tracker.start("zip-list")
@@ -1017,6 +1030,7 @@ def download_and_extract_template(
10171030
if is_current_dir:
10181031
with tempfile.TemporaryDirectory() as temp_dir:
10191032
temp_path = Path(temp_dir)
1033+
_validate_zip_members_within(temp_path)
10201034
zip_ref.extractall(temp_path)
10211035

10221036
extracted_items = list(temp_path.iterdir())
@@ -1065,6 +1079,7 @@ def download_and_extract_template(
10651079
if verbose and not tracker:
10661080
console.print("[cyan]Template files merged into current directory[/cyan]")
10671081
else:
1082+
_validate_zip_members_within(project_path)
10681083
zip_ref.extractall(project_path)
10691084

10701085
extracted_items = list(project_path.iterdir())

tests/test_ai_skills.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import tempfile
1717
import shutil
1818
import yaml
19+
import typer
1920
from pathlib import Path
2021
from unittest.mock import patch
2122

@@ -830,6 +831,39 @@ def test_codex_ai_skills_fresh_dir_does_not_create_codex_dir(self, tmp_path):
830831
assert (target / ".specify").exists()
831832
assert not (target / ".codex").exists()
832833

834+
@pytest.mark.parametrize("is_current_dir", [False, True])
835+
def test_download_and_extract_template_blocks_zip_path_traversal(self, tmp_path, monkeypatch, is_current_dir):
836+
"""Extraction should reject ZIP members escaping the target directory."""
837+
target = (tmp_path / "here-proj") if is_current_dir else (tmp_path / "new-proj")
838+
if is_current_dir:
839+
target.mkdir()
840+
monkeypatch.chdir(target)
841+
842+
archive = tmp_path / "malicious-template.zip"
843+
with zipfile.ZipFile(archive, "w") as zf:
844+
zf.writestr("../evil.txt", "pwned")
845+
zf.writestr("template-root/.specify/templates/constitution-template.md", "constitution")
846+
847+
fake_meta = {
848+
"filename": archive.name,
849+
"size": archive.stat().st_size,
850+
"release": "vtest",
851+
"asset_url": "https://example.invalid/template.zip",
852+
}
853+
854+
with patch("specify_cli.download_template_from_github", return_value=(archive, fake_meta)):
855+
with pytest.raises(typer.Exit):
856+
specify_cli.download_and_extract_template(
857+
target,
858+
"codex",
859+
"sh",
860+
is_current_dir=is_current_dir,
861+
skip_legacy_codex_prompts=True,
862+
verbose=False,
863+
)
864+
865+
assert not (tmp_path / "evil.txt").exists()
866+
833867
def test_commands_preserved_when_skills_fail(self, tmp_path):
834868
"""If skills fail, commands should NOT be removed (safety net)."""
835869
from typer.testing import CliRunner

0 commit comments

Comments
 (0)