Skip to content

Commit bb2b49d

Browse files
fix(workflows): validate run_id in RunState.load before touching the … (#2813)
* fix(workflows): validate run_id in RunState.load before touching the filesystem ``RunState.load(run_id, project_root)`` interpolates ``run_id`` directly into ``project_root / ".specify" / "workflows" / "runs" / run_id`` and then calls ``state_path.exists()`` and ``json.load`` on the result. The run_id is reachable from user input via ``specify workflow resume <run_id>`` (CLI argument) and via ``SPECKIT_WORKFLOW_RUN_ID`` (env var override on the engine's run path), so a value like ``../escape`` turns ``runs_dir`` into ``.specify/workflows/escape/`` and: * ``state_path.exists()`` becomes a file-existence oracle for any path the process can read. * if a ``state.json`` exists at the traversed location (planted by a malicious dependency, a misconfigured shared workspace, or an older spec-kit version that happened to write there), ``json.load`` parses it and the workflow resumes under the attacker-chosen ``workflow_id`` / step state. * a subsequent ``state.save()`` then writes back to the traversed location, persisting the corruption. ``RunState.__init__`` already validates ``run_id`` against ``r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$'`` — but that check runs on ``state_data["run_id"]`` *after* ``load`` has already done the file lookup, which is too late to prevent the disclosure. This change extracts the pattern into a class-level constant ``_RUN_ID_PATTERN`` and a single ``_validate_run_id`` classmethod so ``__init__`` and ``load`` cannot drift, then calls the validator at the top of ``load`` before any path is built. Mirrors the precedent in ``src/specify_cli/agents.py::_ensure_within_directory`` (used at line 437 of that file) which guards extension-install paths against the same threat model. Regression tests parametrize 9 traversal vectors (``../escape``, ``..``, ``../../etc/passwd``, ``foo/bar``, ``foo\bar``, ``.hidden``, ``-flag``, ``foo\x00bar``, empty) and plant a malicious ``state.json`` outside ``runs/`` so a missing guard would surface as a successful load rather than the ambiguous ``FileNotFoundError``. A second test asserts ``__init__`` and ``load`` reject the same representative malformed ID, so future changes to one path can't silently drift from the other. * test(workflows): exercise RunState.load in shared-validation test, fix __init__ empty-string asymmetry Copilot's review on this PR pointed out that test_init_and_load_share_validation claimed to verify both entry points share the same validation rules but never actually called RunState.load — only __init__ and the shared _validate_run_id helper. A regression in load (e.g. someone deleting the cls._validate_run_id(run_id) call before the path is built) would slip through even though __init__ and the helper stayed aligned, defeating the whole point of the test. Tightening the test surfaced a real asymmetry the previous version was silently masking: self.run_id = run_id or str(uuid.uuid4())[:8] The truthiness fallback meant RunState(run_id="") silently substituted a UUID and skipped validation, while RunState.load("", project_root) correctly rejected the empty string. The two entry points diverged on the empty-string vector. That is exactly the drift the test name claimed to defend against — and the original test missed it. Changes ------- * engine.py: __init__ now distinguishes run_id is None (caller omitted it → auto-generate UUID) from an empty string (caller provided it → must validate like any other value). Both paths still flow through _validate_run_id, but only the explicit-None case auto-generates. * test_workflows.py: test_init_and_load_share_validation is now parametrized over one representative vector per category from test_load_rejects_path_traversal (parent traversal, embedded separator, leading non-alphanumeric, empty string) and asserts that *all three* entry points — __init__, _validate_run_id, and load — reject the same input. Adding load to the assertion is the substantive fix Copilot asked for; keeping __init__ and the helper alongside it makes any future drift between the three immediately observable instead of having to read three separate tests. Verification ------------ pytest tests/test_workflows.py — 168 passed (was 165 before the parametrize expansion; __init__ empty-string vector would have failed the new test against the old engine code, confirming the asymmetry was real).
1 parent ac2cb5d commit bb2b49d

2 files changed

Lines changed: 157 additions & 5 deletions

File tree

src/specify_cli/workflows/engine.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -281,16 +281,49 @@ def _validate_steps(
281281
class RunState:
282282
"""Manages workflow run state for persistence and resume."""
283283

284+
# ``run_id`` is interpolated into a filesystem path (``runs/<run_id>``)
285+
# by both ``save()`` and ``load()``. Constrain it to a charset that
286+
# cannot contain path separators (``/`` ``\``), parent-directory
287+
# segments (``..``), or NULs — anything that could escape the
288+
# ``.specify/workflows/runs/`` directory or be mis-interpreted by the
289+
# filesystem. The first-character anchor blocks IDs that start with
290+
# ``-`` (which would be mistaken for a CLI flag in error messages
291+
# and shell completions).
292+
_RUN_ID_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
293+
294+
@classmethod
295+
def _validate_run_id(cls, run_id: str) -> None:
296+
"""Raise ``ValueError`` if ``run_id`` is not a safe path component.
297+
298+
This is the single source of truth for what counts as a valid
299+
``run_id``. ``__init__`` calls it to reject malformed IDs at
300+
construction time; ``load`` calls it *before* interpolating the
301+
ID into a path so a malicious value cannot probe or read files
302+
outside ``.specify/workflows/runs/<run_id>/``.
303+
"""
304+
if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.match(run_id):
305+
raise ValueError(
306+
f"Invalid run_id {run_id!r}: must be alphanumeric with "
307+
"hyphens/underscores only (and must start with an "
308+
"alphanumeric character)."
309+
)
310+
284311
def __init__(
285312
self,
286313
run_id: str | None = None,
287314
workflow_id: str = "",
288315
project_root: Path | None = None,
289316
) -> None:
290-
self.run_id = run_id or str(uuid.uuid4())[:8]
291-
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$', self.run_id):
292-
msg = f"Invalid run_id {self.run_id!r}: must be alphanumeric with hyphens/underscores only."
293-
raise ValueError(msg)
317+
# ``run_id is None`` (omitted) → auto-generate. An explicit empty
318+
# string is *not* the same as "omitted" and must be validated like
319+
# any other caller-provided value — otherwise ``__init__("")``
320+
# would silently substitute a UUID while ``load("")`` rejects, and
321+
# the two entry points would diverge on the empty-string vector.
322+
if run_id is None:
323+
self.run_id = str(uuid.uuid4())[:8]
324+
else:
325+
self.run_id = run_id
326+
self._validate_run_id(self.run_id)
294327
self.workflow_id = workflow_id
295328
self.project_root = project_root or Path(".")
296329
self.status = RunStatus.CREATED
@@ -331,7 +364,20 @@ def save(self) -> None:
331364

332365
@classmethod
333366
def load(cls, run_id: str, project_root: Path) -> RunState:
334-
"""Load a run state from disk."""
367+
"""Load a run state from disk.
368+
369+
Validates ``run_id`` against ``_RUN_ID_PATTERN`` *before* building
370+
the lookup path. Without this guard, a caller passing a value like
371+
``../escape`` (e.g. via ``specify workflow resume`` CLI argument)
372+
would interpolate path-traversal segments into
373+
``runs_dir`` below, letting ``state_path.exists()`` probe arbitrary
374+
paths and ``json.load`` read attacker-planted JSON from outside
375+
the project's ``runs/`` directory. ``__init__`` already runs this
376+
check on the stored ``state_data["run_id"]``, but that fires
377+
*after* the file lookup — too late to prevent the disclosure.
378+
Mirrors the precedent in ``agents._ensure_within_directory``.
379+
"""
380+
cls._validate_run_id(run_id)
335381
runs_dir = project_root / ".specify" / "workflows" / "runs" / run_id
336382
state_path = runs_dir / "state.json"
337383
if not state_path.exists():

tests/test_workflows.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2716,6 +2716,112 @@ def test_load_not_found(self, project_dir):
27162716
with pytest.raises(FileNotFoundError):
27172717
RunState.load("nonexistent", project_dir)
27182718

2719+
@pytest.mark.parametrize(
2720+
"malicious_run_id",
2721+
[
2722+
# Parent-directory traversal — the classic path-escape vector.
2723+
"../escape",
2724+
"..",
2725+
"../../etc/passwd",
2726+
# Embedded path separators — both POSIX and Windows.
2727+
"foo/bar",
2728+
"foo\\bar",
2729+
# Leading non-alphanumeric characters that the existing
2730+
# pattern's anchor blocks (would be mistaken for CLI flags
2731+
# or hidden files in shell completions / error messages).
2732+
".hidden",
2733+
"-flag",
2734+
# NUL byte — some filesystems treat the prefix as a valid
2735+
# path and silently truncate at the NUL.
2736+
"foo\x00bar",
2737+
# Empty string — degenerate case, matches no file but the
2738+
# validator should reject it before any I/O.
2739+
"",
2740+
],
2741+
)
2742+
def test_load_rejects_path_traversal(self, project_dir, malicious_run_id):
2743+
"""``RunState.load`` validates ``run_id`` before touching the
2744+
filesystem.
2745+
2746+
Without this guard, a value like ``../escape`` passed via
2747+
``specify workflow resume`` would interpolate path-traversal
2748+
segments into the lookup path. ``state_path.exists()`` would
2749+
probe arbitrary paths the process can read (a file-existence
2750+
oracle) and ``json.load`` would happily parse attacker-planted
2751+
JSON from outside ``.specify/workflows/runs/``. The check must
2752+
fire *before* the path is built — ``__init__``'s identical
2753+
regex on ``state_data["run_id"]`` fires too late.
2754+
"""
2755+
from specify_cli.workflows.engine import RunState
2756+
2757+
# Plant a state.json *outside* the legitimate ``runs/`` directory
2758+
# at the location ``../escape`` would traverse to, so a missing
2759+
# guard would surface as a successful load rather than a
2760+
# ``FileNotFoundError`` (which would be ambiguous with the
2761+
# not-found case).
2762+
runs_dir = project_dir / ".specify" / "workflows" / "runs"
2763+
runs_dir.mkdir(parents=True, exist_ok=True)
2764+
attacker_dir = project_dir / ".specify" / "workflows" / "escape"
2765+
attacker_dir.mkdir(exist_ok=True)
2766+
(attacker_dir / "state.json").write_text(
2767+
json.dumps(
2768+
{
2769+
"run_id": "pwned",
2770+
"workflow_id": "attacker-owned",
2771+
"status": "created",
2772+
}
2773+
),
2774+
encoding="utf-8",
2775+
)
2776+
2777+
with pytest.raises(ValueError, match="Invalid run_id"):
2778+
RunState.load(malicious_run_id, project_dir)
2779+
2780+
@pytest.mark.parametrize(
2781+
"bad_run_id",
2782+
[
2783+
# One vector per category from ``test_load_rejects_path_traversal``
2784+
# — enough to prove both entry points agree without re-running
2785+
# the full attack matrix here.
2786+
"../escape", # parent-directory traversal
2787+
"foo/bar", # embedded path separator
2788+
".hidden", # leading non-alphanumeric
2789+
"", # empty / degenerate
2790+
],
2791+
)
2792+
def test_init_and_load_share_validation(self, project_dir, bad_run_id):
2793+
"""``__init__`` *and* ``load`` reject the same malformed IDs.
2794+
2795+
The two entry points must stay in sync — drift would let an ID
2796+
slip in via one path that the other would reject, producing
2797+
confusing crashes mid-workflow. The previous version of this
2798+
test only exercised ``__init__`` and ``_validate_run_id`` (the
2799+
shared helper), so a regression in ``load`` — e.g. someone
2800+
deleting the ``cls._validate_run_id(run_id)`` call there — could
2801+
slip through despite ``__init__`` and the helper staying
2802+
aligned. We now hit ``load`` directly with the same vector so
2803+
any drift between the two call sites is caught by this test.
2804+
"""
2805+
from specify_cli.workflows.engine import RunState
2806+
2807+
# ``__init__`` rejects up front.
2808+
with pytest.raises(ValueError, match="Invalid run_id"):
2809+
RunState(run_id=bad_run_id)
2810+
2811+
# The shared helper rejects the value too (sanity check that the
2812+
# ``__init__`` rejection came from the validator, not some
2813+
# unrelated constructor failure).
2814+
with pytest.raises(ValueError, match="Invalid run_id"):
2815+
RunState._validate_run_id(bad_run_id)
2816+
2817+
# And ``load`` rejects it *before* touching the filesystem. This
2818+
# is the assertion the previous version was missing: without it,
2819+
# a regression in ``load`` (e.g. forgetting to call the
2820+
# validator before building the path) would not be caught even
2821+
# though ``__init__`` and the helper still agreed.
2822+
with pytest.raises(ValueError, match="Invalid run_id"):
2823+
RunState.load(bad_run_id, project_dir)
2824+
27192825
def test_append_log(self, project_dir):
27202826
from specify_cli.workflows.engine import RunState
27212827

0 commit comments

Comments
 (0)