Skip to content

Commit 351e23c

Browse files
authored
fix: harden rescue staging dir - symlink checks, secure writes, cleanup errors
- Thread 14: Change except BaseException to except Exception in the staging fallback block so KeyboardInterrupt/SystemExit propagate correctly - Thread 15: Add explanatory comment to the bare pass in the chmod except block to satisfy static analysis - Thread 16: Reject a symlinked staging directory and only reload non-symlinked files whose names match the two recognised config suffixes - Thread 17: Create each staging file via os.open with mode 0600 and O_CREAT|O_EXCL before writing so preserved bytes are never transiently exposed to other local users - Thread 18: Remove ignore_errors=True from the final staging-dir cleanup so a failed rmtree propagates rather than silently leaving a stale backup that could be misread on the next retry Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
1 parent 0f366c0 commit 351e23c

1 file changed

Lines changed: 27 additions & 8 deletions

File tree

src/specify_cli/extensions/__init__.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1410,14 +1410,24 @@ def install_from_directory(
14101410
stranded_configs: dict[str, tuple[bytes, int]] = {}
14111411
rescue_staging_dir = self.extensions_dir / f".rescue-staging-{manifest.id}"
14121412

1413-
if rescue_staging_dir.is_dir() and not self.registry.is_installed(manifest.id):
1413+
if (
1414+
rescue_staging_dir.is_dir()
1415+
and not rescue_staging_dir.is_symlink()
1416+
and not self.registry.is_installed(manifest.id)
1417+
):
14141418
# A previous install attempt staged the configs but never
14151419
# completed cleanly. Reload from the durable backup so the
14161420
# original bytes are used on retry rather than whatever
14171421
# mixture of packaged defaults and partial restores remains
1418-
# on disk.
1422+
# on disk. Only load non-symlinked files whose names match
1423+
# the two recognised config suffixes so a tampered staging
1424+
# directory cannot inject arbitrary files.
14191425
for staged_file in sorted(rescue_staging_dir.iterdir()):
1420-
if staged_file.is_file():
1426+
if (
1427+
staged_file.is_file()
1428+
and not staged_file.is_symlink()
1429+
and staged_file.name.endswith(("-config.yml", "-config.local.yml"))
1430+
):
14211431
stranded_configs[staged_file.name] = (
14221432
staged_file.read_bytes(),
14231433
staged_file.stat().st_mode,
@@ -1442,12 +1452,19 @@ def install_from_directory(
14421452
rescue_staging_dir.mkdir(parents=True, exist_ok=True)
14431453
for filename, (content, mode) in stranded_configs.items():
14441454
staged = rescue_staging_dir / filename
1445-
staged.write_bytes(content)
1455+
# Create the staging file with mode 0600 before writing so
1456+
# the preserved bytes are never transiently readable by other
1457+
# local users, even on a umask that would produce 0644.
1458+
fd = os.open(str(staged), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
1459+
try:
1460+
os.write(fd, content)
1461+
finally:
1462+
os.close(fd)
14461463
try:
14471464
staged.chmod(mode)
14481465
except (NotImplementedError, OSError):
1449-
pass
1450-
except BaseException:
1466+
pass # Best-effort; chmod may not be supported on all platforms.
1467+
except Exception:
14511468
# Staging failed — clean up any partial marker dir and
14521469
# fall back to in-memory protection only (same as the
14531470
# pre-staging behaviour).
@@ -1498,9 +1515,11 @@ def _restore_stranded_config_file(
14981515
_restore_stranded_config_file(target, content, mode)
14991516

15001517
# Every stranded config has been restored successfully — the
1501-
# durable staging backup is no longer needed.
1518+
# durable staging backup is no longer needed. Raise on failure so
1519+
# the install is not reported as successful while a stale backup
1520+
# that could be misread on the next retry remains on disk.
15021521
if rescue_staging_dir.is_dir():
1503-
shutil.rmtree(rescue_staging_dir, ignore_errors=True)
1522+
shutil.rmtree(rescue_staging_dir)
15041523

15051524
# Register commands with AI agents
15061525
registered_commands = {}

0 commit comments

Comments
 (0)