fix(workflows): reject symlinked overlay directories in layer sources

Address PR #3557 review comments r3594064534 and r3594064563:

- ProjectOverlaySource.collect now rejects symlinked per-workflow overlay
  directories (.specify/workflows/overlays/<id>) before iterating
- InstalledOverlaySource.collect now rejects symlinked installed overlay
  directories (.specify/workflows/<id>/overlays) before iterating
- workflow_overlay_list catches ValueError from resolver and exits with
  code 1 instead of crashing on unhandled exceptions
- Added .specify/workflows/overlays to _reject_unsafe_workflow_storage
  chokepoint for defense-in-depth

These guards prevent symlinked overlay directories from redirecting
auto-loaded overlay YAML to attacker-controlled content outside the
project, which could inject executable shell steps into trusted workflows.

Refs: PR #3557 review comments r3594064534, r3594064563

Assisted-by: opencode-go/qwen3.7-max (autonomous)
This commit is contained in:
Markus
2026-07-16 14:47:38 +02:00
parent 14a8d2fadb
commit 01e0455f25
5 changed files with 186 additions and 4 deletions

View File

@@ -109,6 +109,10 @@ def _reject_unsafe_workflow_storage(project_root: Path) -> None:
project_root / ".specify" / "workflows" / "runs",
".specify/workflows/runs",
)
_reject_unsafe_dir(
project_root / ".specify" / "workflows" / "overlays",
".specify/workflows/overlays",
)
_WORKFLOW_ID_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
@@ -1818,7 +1822,8 @@ def workflow_overlay_list_cmd(
from .overlays._commands import workflow_overlay_list
project_root = _require_specify_project()
workflow_overlay_list(project_root, workflow_id)
if workflow_overlay_list(project_root, workflow_id) is None:
raise typer.Exit(1)
@workflow_app.command("resolve")

View File

@@ -284,14 +284,18 @@ def workflow_overlay_remove(
return True
def workflow_overlay_list(project_root: Path, workflow_id: str) -> list[dict[str, Any]]:
def workflow_overlay_list(project_root: Path, workflow_id: str) -> list[dict[str, Any]] | None:
"""List all overlays for a workflow and print a summary table.
Returns the raw list data for machine-readable callers.
Returns the raw list data for machine-readable callers, or None on error.
"""
_validate_workflow_id_or_exit(workflow_id)
resolver = WorkflowResolver(project_root)
layers = resolver.collect_all_layers(workflow_id)
try:
layers = resolver.collect_all_layers(workflow_id)
except ValueError as exc:
err_console.print(f"[red]Error:[/red] {exc}")
return None
overlays = [layer for layer in layers if layer.tier != "base"]
if not overlays:

View File

@@ -42,6 +42,11 @@ class ProjectOverlaySource:
def collect(self, workflow_id: str) -> list[Layer]:
"""Collect all project-local overlays for the given workflow id."""
workflow_overlay_dir = self.overlays_dir / workflow_id
if workflow_overlay_dir.is_symlink():
raise OverlayLoadError(
workflow_overlay_dir,
["Symlinked overlay directories are not allowed"],
)
if not workflow_overlay_dir.is_dir():
return []
layers: list[Layer] = []
@@ -82,6 +87,11 @@ class InstalledOverlaySource:
def collect(self, workflow_id: str) -> list[Layer]:
"""Collect all installed overlays shipped with the given workflow."""
installed_overlay_dir = self.workflows_dir / workflow_id / "overlays"
if installed_overlay_dir.is_symlink():
raise OverlayLoadError(
installed_overlay_dir,
["Symlinked overlay directories are not allowed"],
)
if not installed_overlay_dir.is_dir():
return []
layers: list[Layer] = []