Skip to content

Commit 324d49c

Browse files
committed
Prevent workflow commands from targeting reserved storage
Workflow install and removal paths are derived from workflow IDs before any catalog download, local copy, or directory deletion. Validate that IDs are single workflow-id path segments and reject names reserved for workflow runtime storage so commands cannot target .specify/workflows/runs or .specify/workflows/steps.
1 parent 77301a5 commit 324d49c

2 files changed

Lines changed: 177 additions & 2 deletions

File tree

src/specify_cli/workflows/_commands.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import contextlib
1111
import json
1212
import os
13+
import re
1314
import sys
1415
from pathlib import Path
1516
from typing import Any
@@ -90,6 +91,22 @@ def _reject_unsafe_workflow_storage(project_root: Path) -> None:
9091
)
9192

9293

94+
_WORKFLOW_ID_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
95+
_RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"runs", "steps"})
96+
97+
98+
def _validate_workflow_id_or_exit(workflow_id: str) -> None:
99+
"""Validate that ``workflow_id`` is a safe installed-workflow directory name."""
100+
if (
101+
workflow_id in _RESERVED_WORKFLOW_IDS
102+
or not _WORKFLOW_ID_PATTERN.match(workflow_id)
103+
):
104+
console.print(
105+
f"[red]Error:[/red] Invalid workflow ID: {_escape_markup(repr(workflow_id))}"
106+
)
107+
raise typer.Exit(1)
108+
109+
93110
def _safe_workflow_id_dir(workflows_dir: Path, workflow_id: str) -> Path:
94111
"""Validate the per-id install directory before any write and return it.
95112
@@ -100,6 +117,8 @@ def _safe_workflow_id_dir(workflows_dir: Path, workflow_id: str) -> Path:
100117
101118
- an ``<id>`` that is a symlink or an existing non-directory
102119
(the latter would otherwise make ``mkdir`` raise);
120+
- an ``<id>`` that is not a single workflow-id path segment or collides
121+
with internal workflow storage directories;
103122
- an ``<id>`` that escapes ``workflows_dir`` (path traversal);
104123
- an ``<id>/workflow.yml`` leaf that is a symlink or an existing
105124
non-file (either would otherwise make the later write/copy raise).
@@ -109,6 +128,8 @@ def _safe_workflow_id_dir(workflows_dir: Path, workflow_id: str) -> Path:
109128
``workflow_id`` is markup-escaped in output to avoid Rich markup injection.
110129
"""
111130
safe_id = _escape_markup(workflow_id)
131+
_validate_workflow_id_or_exit(workflow_id)
132+
112133
dest_dir = workflows_dir / workflow_id
113134
_reject_unsafe_dir(dest_dir, f".specify/workflows/{safe_id}")
114135
try:
@@ -801,17 +822,54 @@ def workflow_remove(
801822
from .catalog import WorkflowRegistry
802823

803824
project_root = _require_specify_project()
825+
workflows_dir = project_root / ".specify" / "workflows"
826+
_validate_workflow_id_or_exit(workflow_id)
827+
804828
registry = WorkflowRegistry(project_root)
805829

806830
if not registry.is_installed(workflow_id):
807831
console.print(f"[red]Error:[/red] Workflow '{workflow_id}' is not installed")
808832
raise typer.Exit(1)
809833

810834
# Remove workflow files
811-
workflow_dir = project_root / ".specify" / "workflows" / workflow_id
835+
workflow_dir_unresolved = workflows_dir / workflow_id
836+
safe_id = _escape_markup(workflow_id)
837+
if workflow_dir_unresolved.is_symlink():
838+
console.print(
839+
f"[red]Error:[/red] Refusing to remove symlinked "
840+
f".specify/workflows/{safe_id}"
841+
)
842+
raise typer.Exit(1)
843+
844+
workflow_dir = workflow_dir_unresolved.resolve()
845+
try:
846+
rel_parts = workflow_dir.relative_to(workflows_dir.resolve()).parts
847+
except ValueError:
848+
console.print(
849+
f"[red]Error:[/red] Invalid workflow ID: {_escape_markup(repr(workflow_id))}"
850+
)
851+
raise typer.Exit(1)
852+
if rel_parts != (workflow_id,):
853+
console.print(
854+
f"[red]Error:[/red] Invalid workflow ID: {_escape_markup(repr(workflow_id))}"
855+
)
856+
raise typer.Exit(1)
857+
858+
if workflow_dir.exists() and not workflow_dir.is_dir():
859+
console.print(
860+
f"[red]Error:[/red] .specify/workflows/{safe_id} exists but is not a directory"
861+
)
862+
raise typer.Exit(1)
863+
812864
if workflow_dir.exists():
813865
import shutil
814-
shutil.rmtree(workflow_dir)
866+
try:
867+
shutil.rmtree(workflow_dir)
868+
except OSError as exc:
869+
console.print(
870+
f"[red]Error:[/red] Failed to remove workflow directory {workflow_dir}: {exc}"
871+
)
872+
raise typer.Exit(1)
815873

816874
registry.remove(workflow_id)
817875
console.print(f"[green]✓[/green] Workflow '{workflow_id}' removed")

tests/test_workflows.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5303,6 +5303,95 @@ def test_remove_rejects_symlinked_steps_base_dir(self, project_dir, monkeypatch)
53035303
assert "Refusing to use symlinked step directory" in result.output
53045304

53055305

5306+
class TestWorkflowRemoveGuard:
5307+
def test_remove_rejects_traversal_registry_key(self, project_dir, monkeypatch):
5308+
"""A corrupted registry key must not let remove delete outside workflows/."""
5309+
from typer.testing import CliRunner
5310+
from specify_cli import app
5311+
from specify_cli.workflows.catalog import WorkflowRegistry
5312+
5313+
registry = WorkflowRegistry(project_dir)
5314+
registry.add("../outside", {"name": "Bad"})
5315+
outside = project_dir / ".specify" / "outside"
5316+
outside.mkdir()
5317+
sentinel = outside / "keep.txt"
5318+
sentinel.write_text("keep", encoding="utf-8")
5319+
5320+
monkeypatch.chdir(project_dir)
5321+
result = CliRunner().invoke(app, ["workflow", "remove", "../outside"])
5322+
5323+
assert result.exit_code != 0
5324+
assert "Invalid workflow ID" in result.output
5325+
assert sentinel.read_text(encoding="utf-8") == "keep"
5326+
5327+
@pytest.mark.parametrize("workflow_id", ["runs", "steps"])
5328+
def test_remove_rejects_reserved_storage_ids(
5329+
self, project_dir, monkeypatch, workflow_id
5330+
):
5331+
"""Reserved workflow storage directories must never be removable workflows."""
5332+
from typer.testing import CliRunner
5333+
from specify_cli import app
5334+
from specify_cli.workflows.catalog import WorkflowRegistry
5335+
5336+
registry = WorkflowRegistry(project_dir)
5337+
registry.add(workflow_id, {"name": "Bad"})
5338+
reserved_dir = project_dir / ".specify" / "workflows" / workflow_id
5339+
reserved_dir.mkdir(exist_ok=True)
5340+
sentinel = reserved_dir / "keep.txt"
5341+
sentinel.write_text("keep", encoding="utf-8")
5342+
5343+
monkeypatch.chdir(project_dir)
5344+
result = CliRunner().invoke(app, ["workflow", "remove", workflow_id])
5345+
5346+
assert result.exit_code != 0
5347+
assert "Invalid workflow ID" in result.output
5348+
assert sentinel.read_text(encoding="utf-8") == "keep"
5349+
5350+
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
5351+
def test_remove_refuses_symlinked_workflow_dir(self, project_dir, monkeypatch):
5352+
"""A symlinked workflow directory must not let remove delete its target."""
5353+
from typer.testing import CliRunner
5354+
from specify_cli import app
5355+
from specify_cli.workflows.catalog import WorkflowRegistry
5356+
5357+
registry = WorkflowRegistry(project_dir)
5358+
registry.add("test-wf", {"name": "Test"})
5359+
outside = project_dir / "outside-workflow-remove-target"
5360+
outside.mkdir(exist_ok=True)
5361+
sentinel = outside / "keep.txt"
5362+
sentinel.write_text("keep", encoding="utf-8")
5363+
(project_dir / ".specify" / "workflows" / "test-wf").symlink_to(
5364+
outside, target_is_directory=True
5365+
)
5366+
5367+
monkeypatch.chdir(project_dir)
5368+
result = CliRunner().invoke(app, ["workflow", "remove", "test-wf"])
5369+
5370+
assert result.exit_code != 0
5371+
assert "symlinked .specify/workflows/test-wf" in result.output
5372+
assert sentinel.read_text(encoding="utf-8") == "keep"
5373+
assert WorkflowRegistry(project_dir).is_installed("test-wf")
5374+
5375+
def test_remove_refuses_non_directory_workflow_path(self, project_dir, monkeypatch):
5376+
"""A file at the workflow path must fail cleanly instead of crashing."""
5377+
from typer.testing import CliRunner
5378+
from specify_cli import app
5379+
from specify_cli.workflows.catalog import WorkflowRegistry
5380+
5381+
registry = WorkflowRegistry(project_dir)
5382+
registry.add("test-wf", {"name": "Test"})
5383+
workflow_path = project_dir / ".specify" / "workflows" / "test-wf"
5384+
workflow_path.write_text("not a directory", encoding="utf-8")
5385+
5386+
monkeypatch.chdir(project_dir)
5387+
result = CliRunner().invoke(app, ["workflow", "remove", "test-wf"])
5388+
5389+
assert result.exit_code != 0
5390+
assert "exists but is not a directory" in result.output
5391+
assert workflow_path.read_text(encoding="utf-8") == "not a directory"
5392+
assert WorkflowRegistry(project_dir).is_installed("test-wf")
5393+
5394+
53065395
class TestWorkflowAddSymlinkGuard:
53075396
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
53085397
def test_add_refuses_symlinked_specify(self, temp_dir, monkeypatch):
@@ -5439,6 +5528,34 @@ def test_safe_workflow_id_dir_escapes_markup_in_invalid_id(self, temp_dir, capsy
54395528
# Literal bracketed text survives; Rich did not consume it as a tag.
54405529
assert "[red]evil[/red]" in out
54415530

5531+
@pytest.mark.parametrize(
5532+
"workflow_id",
5533+
[
5534+
"runs",
5535+
"steps",
5536+
"nested/workflow",
5537+
"nested\\workflow",
5538+
"bad id",
5539+
" bad-id",
5540+
"bad-id ",
5541+
],
5542+
)
5543+
def test_safe_workflow_id_dir_rejects_reserved_or_non_segment_ids(
5544+
self, temp_dir, workflow_id, capsys
5545+
):
5546+
"""Install IDs must not collide with workflow internals or create nested paths."""
5547+
import typer
5548+
from specify_cli.workflows._commands import _safe_workflow_id_dir
5549+
5550+
workflows_dir = temp_dir / ".specify" / "workflows"
5551+
workflows_dir.mkdir(parents=True)
5552+
5553+
with pytest.raises(typer.Exit):
5554+
_safe_workflow_id_dir(workflows_dir, workflow_id)
5555+
5556+
assert "Invalid workflow ID" in capsys.readouterr().out
5557+
assert not (workflows_dir / workflow_id).exists()
5558+
54425559
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
54435560
def test_list_refuses_symlinked_runs_dir(self, temp_dir, monkeypatch):
54445561
"""workflow commands using the project shim must refuse symlinked run storage."""

0 commit comments

Comments
 (0)