Skip to content

Commit 83ef225

Browse files
jawwad-aliclaude
andcommitted
fix(extensions): keep-config restore must not follow symlinks; split mode/time restore
Two hardening fixes to the reinstall keep-config restore loop: 1. Never write *through* a non-regular file at the config path. If dest_cfg is a symlink (copied with symlinks=True, or swapped in by a racing process between copytree and the restore), write_bytes() would follow it and clobber an arbitrary target outside the extension dir. Drop any symlink (unlink, never follow) or stray directory (rmtree) first, so write_bytes() always creates a fresh regular file — and skip the entry if the path can't be made safe rather than clobber. 2. Restore mode and mtime independently. The single try/except meant a chmod failure skipped os.utime (and vice versa); either can succeed on its own, so each gets its own guard. Tests: a symlink-at-path case (proves the write doesn't follow the link to an external victim; skipped where symlinks need privilege, runs on CI) and a directory-at-path case (cross-platform: fails/crashes before the guard, passes after). Behavior for the normal regular-file case is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4a885d7 commit 83ef225

2 files changed

Lines changed: 110 additions & 0 deletions

File tree

src/specify_cli/extensions/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1441,9 +1441,27 @@ def install_from_directory(
14411441
# path, which uses .backup/ rather than an in-place leftover.
14421442
for name, (data, mode, atime, mtime) in preserved_configs.items():
14431443
dest_cfg = dest_dir / name
1444+
# Never write *through* a symlink (or a non-regular-file) at this
1445+
# path: if dest_cfg is a symlink — copied with symlinks=True, or
1446+
# swapped in by a racing process between the copytree above and
1447+
# here — write_bytes() would follow it and clobber an arbitrary
1448+
# target outside the extension dir. Drop any non-regular entry
1449+
# first so write_bytes() always creates a fresh regular file.
1450+
try:
1451+
if dest_cfg.is_symlink():
1452+
dest_cfg.unlink() # remove the link itself, never follow it
1453+
elif dest_cfg.is_dir():
1454+
shutil.rmtree(dest_cfg)
1455+
except OSError:
1456+
continue # can't make the path safe — skip rather than clobber
14441457
dest_cfg.write_bytes(data)
1458+
# Restore mode and timestamps independently: a failure to set one
1459+
# must not prevent the other, since either can succeed on its own.
14451460
try:
14461461
os.chmod(dest_cfg, mode & 0o7777) # permission bits only
1462+
except OSError:
1463+
pass
1464+
try:
14471465
os.utime(dest_cfg, (atime, mtime)) # and timestamps
14481466
except OSError:
14491467
pass

tests/test_extensions.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1591,6 +1591,98 @@ def test_reinstall_preserves_keep_config_leftover(self, extension_dir, project_d
15911591
if platform.system() != "Windows":
15921592
assert config_file.stat().st_mode & 0o777 == 0o600
15931593

1594+
def test_reinstall_config_restore_does_not_follow_symlink(
1595+
self, extension_dir, project_dir, monkeypatch
1596+
):
1597+
"""The keep-config restore must never write *through* a symlink. If the
1598+
config path is a symlink at restore time (e.g. swapped in between the
1599+
copytree and the restore), the write must not follow it and clobber the
1600+
external target — it must replace the link with a fresh regular file."""
1601+
# Probe symlink capability (Windows usually needs privilege).
1602+
probe_target = project_dir / "_probe_target"
1603+
probe_link = project_dir / "_probe_link"
1604+
try:
1605+
probe_target.write_text("x")
1606+
probe_link.symlink_to(probe_target)
1607+
except (OSError, NotImplementedError):
1608+
pytest.skip("symlinks not supported in this environment")
1609+
finally:
1610+
probe_link.unlink(missing_ok=True)
1611+
probe_target.unlink(missing_ok=True)
1612+
1613+
manager = ExtensionManager(project_dir)
1614+
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
1615+
1616+
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
1617+
config_file = ext_dir / "test-ext-config.yml"
1618+
config_file.write_text("api_key: MY-CUSTOMIZED-VALUE")
1619+
1620+
# keep-config removal: registry entry dropped, config left in place.
1621+
assert manager.remove("test-ext", keep_config=True) is True
1622+
1623+
# A victim file outside the extension dir that the symlink points at.
1624+
victim = project_dir / "victim.txt"
1625+
victim.write_text("DO-NOT-OVERWRITE")
1626+
1627+
# Simulate the config path being a symlink to the victim right after the
1628+
# fresh copytree (the TOCTOU / symlinks=True case the guard defends).
1629+
real_copytree = shutil.copytree
1630+
1631+
def copytree_then_symlink(src, dst, *args, **kwargs):
1632+
result = real_copytree(src, dst, *args, **kwargs)
1633+
link = Path(dst) / "test-ext-config.yml"
1634+
if link.exists() or link.is_symlink():
1635+
link.unlink()
1636+
link.symlink_to(victim)
1637+
return result
1638+
1639+
monkeypatch.setattr(shutil, "copytree", copytree_then_symlink)
1640+
1641+
# Reinstall: restore must drop the symlink, not follow it.
1642+
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
1643+
1644+
# The victim outside the extension dir is untouched.
1645+
assert victim.read_text() == "DO-NOT-OVERWRITE"
1646+
# The config path is now a regular file (not a symlink) with the
1647+
# preserved content written in place.
1648+
assert not config_file.is_symlink()
1649+
assert config_file.is_file()
1650+
assert "MY-CUSTOMIZED-VALUE" in config_file.read_text()
1651+
1652+
def test_reinstall_config_restore_replaces_directory_at_path(
1653+
self, extension_dir, project_dir, monkeypatch
1654+
):
1655+
"""Cross-platform guard check: if a directory occupies the config path at
1656+
restore time, write_bytes() would raise (uncaught, crashing the whole
1657+
reinstall). The guard must clear the directory and write a fresh regular
1658+
file. Exercises the same non-regular-file guard as the symlink case, but
1659+
needs no symlink privilege so it runs everywhere (incl. Windows)."""
1660+
manager = ExtensionManager(project_dir)
1661+
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
1662+
1663+
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
1664+
config_file = ext_dir / "test-ext-config.yml"
1665+
config_file.write_text("api_key: MY-CUSTOMIZED-VALUE")
1666+
assert manager.remove("test-ext", keep_config=True) is True
1667+
1668+
# Simulate a directory occupying the config path right after copytree.
1669+
real_copytree = shutil.copytree
1670+
1671+
def copytree_then_dir(src, dst, *args, **kwargs):
1672+
result = real_copytree(src, dst, *args, **kwargs)
1673+
clash = Path(dst) / "test-ext-config.yml"
1674+
clash.mkdir()
1675+
(clash / "sentinel").write_text("stray")
1676+
return result
1677+
1678+
monkeypatch.setattr(shutil, "copytree", copytree_then_dir)
1679+
1680+
# Must not raise, and must restore a regular file with preserved content.
1681+
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
1682+
1683+
assert config_file.is_file()
1684+
assert "MY-CUSTOMIZED-VALUE" in config_file.read_text()
1685+
15941686

15951687
# ===== CommandRegistrar Tests =====
15961688

0 commit comments

Comments
 (0)