Skip to content

Commit f43e2ce

Browse files
committed
address review round 5: ancestor-resolves-outside check + TOCTOU-safe write
- Per-component walk now also re-resolves each existing ancestor and requires it to land under project_root.resolve(). This catches non-symlink directory aliases (e.g. Windows junctions / mount points) that resolve outside the project even though no component is itself a symlink. - ZIP write now uses os.open(O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW, 0o600). O_NOFOLLOW (POSIX) refuses a symlink swapped in between the cache validation and the write, closing the TOCTOU window. O_EXCL guarantees the unique UUID filename is freshly created and cannot be pre-staged. O_NOFOLLOW falls back to 0 on Windows. - Add TestExtensionAddFromAncestorEscape: simulates a junction-like alias by patching Path.resolve() so .cache resolves outside; command must refuse with 'escapes project root'. - Add TestExtensionAddFromTOCTOUWrite: races a symlink swap into the cache between validation and write; O_NOFOLLOW/O_EXCL must reject.
1 parent 77b083c commit f43e2ce

2 files changed

Lines changed: 117 additions & 5 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3646,19 +3646,29 @@ def extension_add(
36463646
# (consistent with shared_infra._ensure_safe_shared_directory):
36473647
# `mkdir(parents=True)` would silently traverse a symlinked ancestor,
36483648
# so each component is checked and created individually instead.
3649+
# Each existing component is also re-resolved and required to land
3650+
# under project_root, which catches non-symlink directory aliases
3651+
# (e.g. Windows junctions / mount points) that resolve outside.
36493652
import uuid as _uuid
36503653
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
3654+
project_root_resolved = project_root.resolve()
36513655
_current = project_root
36523656
for _part in download_dir.relative_to(project_root).parts:
36533657
_current = _current / _part
36543658
if _current.is_symlink():
36553659
console.print("[red]Error:[/red] Refusing to use symlinked download cache directory")
36563660
raise typer.Exit(1)
3657-
if not _current.exists():
3658-
_current.mkdir()
3659-
if _current.is_symlink():
3660-
console.print("[red]Error:[/red] Refusing to use symlinked download cache directory")
3661+
if _current.exists():
3662+
try:
3663+
_current.resolve().relative_to(project_root_resolved)
3664+
except (OSError, ValueError):
3665+
console.print("[red]Error:[/red] Download cache directory escapes project root")
36613666
raise typer.Exit(1)
3667+
continue
3668+
_current.mkdir()
3669+
if _current.is_symlink():
3670+
console.print("[red]Error:[/red] Refusing to use symlinked download cache directory")
3671+
raise typer.Exit(1)
36623672

36633673
safe_name = Path(extension).name.replace("/", "_").replace("\\", "_") or "download"
36643674
safe_name = safe_name[:64] # cap length to avoid filesystem errors
@@ -3676,7 +3686,24 @@ def extension_add(
36763686

36773687
with _open_url(from_url, timeout=60) as response:
36783688
zip_data = response.read()
3679-
zip_path.write_bytes(zip_data)
3689+
# Open with O_NOFOLLOW | O_EXCL to defeat any TOCTOU symlink
3690+
# swap that may have happened between the cache validation
3691+
# above and this write. O_EXCL also guarantees the unique
3692+
# UUID filename is freshly created, never reused. O_NOFOLLOW
3693+
# is a no-op on Windows where the constant is absent.
3694+
_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
3695+
_fd = os.open(zip_path, _flags, 0o600)
3696+
try:
3697+
with os.fdopen(_fd, "wb") as _zf:
3698+
_zf.write(zip_data)
3699+
except BaseException:
3700+
# os.fdopen took ownership only on success; on failure
3701+
# before the with-block, the fd may still be open.
3702+
try:
3703+
os.close(_fd)
3704+
except OSError:
3705+
pass
3706+
raise
36803707

36813708
# Install from downloaded ZIP
36823709
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)

tests/test_extension_add_path_traversal.py

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

8+
from pathlib import Path
89
from unittest.mock import MagicMock, patch
910

1011
import pytest
@@ -145,3 +146,87 @@ def test_symlinked_ancestor_is_refused(self, tmp_path, monkeypatch, ancestor_par
145146
mock_install.assert_not_called()
146147
# No download was written through the symlink
147148
assert list(outside.iterdir()) == []
149+
150+
151+
class TestExtensionAddFromAncestorEscape:
152+
"""A non-symlink ancestor whose ``.resolve()`` lands outside the project must be refused.
153+
154+
Simulates the Windows junction / mount-point case using a manually-pre-resolved
155+
directory swap: we cannot create real junctions on POSIX, but we can patch the
156+
``.resolve()`` of an existing ancestor to land outside the project root. The
157+
production guard must reject it before any download happens.
158+
"""
159+
160+
def test_ancestor_resolves_outside_project_is_refused(self, tmp_path, monkeypatch):
161+
project_dir = tmp_path / "project"
162+
project_dir.mkdir()
163+
outside = tmp_path / "outside"
164+
outside.mkdir()
165+
monkeypatch.chdir(project_dir)
166+
167+
# Pre-create the cache ancestors as real directories so the per-component
168+
# walk reaches the resolve()/relative_to() check on each existing one.
169+
cache_root = project_dir / ".specify" / "extensions" / ".cache"
170+
cache_root.mkdir(parents=True)
171+
172+
real_resolve = Path.resolve
173+
174+
def fake_resolve(self, *a, **kw):
175+
# Pretend the .cache ancestor resolves outside the project.
176+
if self == cache_root:
177+
return real_resolve(outside, *a, **kw)
178+
return real_resolve(self, *a, **kw)
179+
180+
with patch.object(Path, "resolve", fake_resolve), \
181+
patch("specify_cli.authentication.http.open_url", return_value=_mock_open_url()), \
182+
patch("specify_cli.extensions.ExtensionManager.install_from_zip") as mock_install:
183+
result = runner.invoke(
184+
app,
185+
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
186+
)
187+
188+
assert result.exit_code != 0
189+
assert "escapes project root" in (result.output or "").lower()
190+
mock_install.assert_not_called()
191+
assert list(outside.iterdir()) == []
192+
193+
194+
class TestExtensionAddFromTOCTOUWrite:
195+
"""The download write must not follow a symlink swapped in after validation."""
196+
197+
def test_swapped_zip_path_symlink_is_refused(self, project_dir):
198+
"""If zip_path is replaced by a symlink between validation and write, refuse."""
199+
cache_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads"
200+
outside = project_dir.parent / "outside"
201+
outside.mkdir(exist_ok=True)
202+
target = outside / "stolen.bin"
203+
204+
# Patch os.open to simulate the TOCTOU window: just before the real
205+
# os.open runs, replace the target path with a symlink pointing outside.
206+
# With O_NOFOLLOW the os.open call must raise instead of writing through.
207+
import os as _os
208+
real_open = _os.open
209+
210+
def racing_open(path, flags, mode=0o777):
211+
try:
212+
p = Path(path)
213+
if p.parent == cache_dir and not p.exists():
214+
p.symlink_to(target)
215+
except OSError:
216+
pass
217+
return real_open(path, flags, mode)
218+
219+
with patch("specify_cli.authentication.http.open_url", return_value=_mock_open_url()), \
220+
patch("specify_cli.extensions.ExtensionManager.install_from_zip") as mock_install, \
221+
patch("os.open", side_effect=racing_open):
222+
result = runner.invoke(
223+
app,
224+
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
225+
)
226+
227+
# Either O_NOFOLLOW (POSIX) or O_EXCL (all platforms) must reject the
228+
# swapped symlink. The command must fail and nothing must be written
229+
# through the symlink to `outside`.
230+
assert result.exit_code != 0
231+
mock_install.assert_not_called()
232+
assert not target.exists(), "write followed swapped symlink"

0 commit comments

Comments
 (0)