Skip to content

Commit b2d796b

Browse files
authored
Harden step_id validation against path-segment tricks; raise on StepRegistry.save() OSError
1 parent 31a3aae commit b2d796b

2 files changed

Lines changed: 51 additions & 14 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5938,6 +5938,27 @@ def workflow_step_list():
59385938
_RESERVED_STEP_IDS: frozenset[str] = frozenset({".cache", "step-registry.json"})
59395939

59405940

5941+
def _validate_step_id_or_exit(step_id: str) -> None:
5942+
"""Validate that ``step_id`` is a single safe path component.
5943+
5944+
Rejects empty strings, path separators, ``.``/``..`` components, dotfile
5945+
prefixes, and reserved names. Exits with code 1 on failure.
5946+
"""
5947+
if (
5948+
not step_id
5949+
or "/" in step_id
5950+
or "\\" in step_id
5951+
or step_id in (".", "..")
5952+
or step_id.startswith(".")
5953+
or step_id in _RESERVED_STEP_IDS
5954+
):
5955+
console.print(
5956+
f"[red]Error:[/red] Invalid step id '{step_id}': must be a single safe "
5957+
"path component (no separators, no leading dot, not a reserved name)"
5958+
)
5959+
raise typer.Exit(1)
5960+
5961+
59415962
@workflow_step_app.command("add")
59425963
def workflow_step_add(
59435964
step_id: str = typer.Argument(..., help="Step type ID from catalog"),
@@ -6027,21 +6048,19 @@ def _safe_fetch(url: str) -> bytes:
60276048
raise ValueError(f"Redirect to URL with no hostname: {final_url}")
60286049
return resp.read()
60296050

6051+
_validate_step_id_or_exit(step_id)
6052+
60306053
steps_base_dir = (project_root / ".specify" / "workflows" / "steps").resolve()
60316054
step_dir = (steps_base_dir / step_id).resolve()
6055+
# Defense-in-depth: ensure the resolved directory is a direct child of
6056+
# steps_base_dir even after symlink resolution.
60326057
try:
6033-
step_dir.relative_to(steps_base_dir)
6058+
rel_parts = step_dir.relative_to(steps_base_dir).parts
60346059
except ValueError:
60356060
console.print(f"[red]Error:[/red] Invalid step id '{step_id}'")
60366061
raise typer.Exit(1)
6037-
6038-
# Reject IDs that collide with internal names used under steps_base_dir
6039-
# (dotfiles, the cache dir, and the registry filename) to prevent
6040-
# corrupting caching or registry persistence.
6041-
if step_id.startswith(".") or step_id in _RESERVED_STEP_IDS:
6042-
console.print(
6043-
f"[red]Error:[/red] '{step_id}' is a reserved name and cannot be used as a step ID"
6044-
)
6062+
if rel_parts != (step_id,):
6063+
console.print(f"[red]Error:[/red] Invalid step id '{step_id}'")
60456064
raise typer.Exit(1)
60466065

60476066
import shutil
@@ -6183,16 +6202,24 @@ def workflow_step_remove(
61836202
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
61846203
raise typer.Exit(1)
61856204

6205+
_validate_step_id_or_exit(step_id)
6206+
61866207
registry = StepRegistry(project_root)
61876208
in_registry = registry.is_installed(step_id)
61886209

61896210
steps_base_dir = (project_root / ".specify" / "workflows" / "steps").resolve()
61906211
step_dir = (steps_base_dir / step_id).resolve()
6212+
# Defense-in-depth: even though _validate_step_id_or_exit rejects path
6213+
# separators, ensure that the resolved directory is a single child of
6214+
# steps_base_dir and is not steps_base_dir itself.
61916215
try:
6192-
step_dir.relative_to(steps_base_dir)
6216+
rel_parts = step_dir.relative_to(steps_base_dir).parts
61936217
except ValueError:
61946218
console.print(f"[red]Error:[/red] Invalid step id '{step_id}'")
61956219
raise typer.Exit(1)
6220+
if rel_parts != (step_id,):
6221+
console.print(f"[red]Error:[/red] Invalid step id '{step_id}'")
6222+
raise typer.Exit(1)
61966223

61976224
dir_exists = step_dir.exists()
61986225

src/specify_cli/workflows/catalog.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -616,10 +616,20 @@ def _load(self) -> dict[str, Any]:
616616
return default_registry
617617

618618
def save(self) -> None:
619-
"""Persist registry to disk."""
620-
self.steps_dir.mkdir(parents=True, exist_ok=True)
621-
with open(self.registry_path, "w", encoding="utf-8") as f:
622-
json.dump(self.data, f, indent=2)
619+
"""Persist registry to disk.
620+
621+
Raises ``StepValidationError`` with a clear message on filesystem
622+
errors (read-only fs, permission denied, ...) so callers can surface
623+
a clean error to the user rather than an unhandled ``OSError``.
624+
"""
625+
try:
626+
self.steps_dir.mkdir(parents=True, exist_ok=True)
627+
with open(self.registry_path, "w", encoding="utf-8") as f:
628+
json.dump(self.data, f, indent=2)
629+
except OSError as exc:
630+
raise StepValidationError(
631+
f"Failed to write step registry at {self.registry_path}: {exc}"
632+
) from exc
623633

624634
def add(self, step_id: str, metadata: dict[str, Any]) -> None:
625635
"""Add or update an installed step entry."""

0 commit comments

Comments
 (0)