``RunState.load`` validates ``run_id`` before touching the filesystem. Without this guard, a value like ``../escape`` passed via ``specify workflow resume`` would interpolate path-traversal segments into the lookup path. ``state_path.exists()`` would probe ar
(self, project_dir, malicious_run_id)
| 4130 | ], |
| 4131 | ) |
| 4132 | def test_load_rejects_path_traversal(self, project_dir, malicious_run_id): |
| 4133 | """``RunState.load`` validates ``run_id`` before touching the |
| 4134 | filesystem. |
| 4135 | |
| 4136 | Without this guard, a value like ``../escape`` passed via |
| 4137 | ``specify workflow resume`` would interpolate path-traversal |
| 4138 | segments into the lookup path. ``state_path.exists()`` would |
| 4139 | probe arbitrary paths the process can read (a file-existence |
| 4140 | oracle) and ``json.load`` would happily parse attacker-planted |
| 4141 | JSON from outside ``.specify/workflows/runs/``. The check must |
| 4142 | fire *before* the path is built — ``__init__``'s identical |
| 4143 | regex on ``state_data["run_id"]`` fires too late. |
| 4144 | """ |
| 4145 | from specify_cli.workflows.engine import RunState |
| 4146 | |
| 4147 | # Plant a state.json *outside* the legitimate ``runs/`` directory |
| 4148 | # at the location ``../escape`` would traverse to, so a missing |
| 4149 | # guard would surface as a successful load rather than a |
| 4150 | # ``FileNotFoundError`` (which would be ambiguous with the |
| 4151 | # not-found case). |
| 4152 | runs_dir = project_dir / ".specify" / "workflows" / "runs" |
| 4153 | runs_dir.mkdir(parents=True, exist_ok=True) |
| 4154 | attacker_dir = project_dir / ".specify" / "workflows" / "escape" |
| 4155 | attacker_dir.mkdir(exist_ok=True) |
| 4156 | (attacker_dir / "state.json").write_text( |
| 4157 | json.dumps( |
| 4158 | { |
| 4159 | "run_id": "pwned", |
| 4160 | "workflow_id": "attacker-owned", |
| 4161 | "status": "created", |
| 4162 | } |
| 4163 | ), |
| 4164 | encoding="utf-8", |
| 4165 | ) |
| 4166 | |
| 4167 | with pytest.raises(ValueError, match="Invalid run_id"): |
| 4168 | RunState.load(malicious_run_id, project_dir) |
| 4169 | |
| 4170 | @pytest.mark.parametrize( |
| 4171 | "bad_run_id", |