@@ -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" )
59425963def 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
0 commit comments