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

@@ -202,3 +202,87 @@ class TestOverlayPathTraversal:
result = runner.invoke(app, ["workflow", "overlay", "enable", "wf", "../other"])
assert result.exit_code != 0, result.output
assert "invalid" in result.output.lower() or "traversal" in result.output.lower()
def test_overlay_rejects_symlinked_overlays_dir(self, project_dir, monkeypatch, tmp_path):
"""Overlay commands must reject a symlinked .specify/workflows/overlays directory."""
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
# Create a symlinked overlays directory pointing outside the project
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
overlays_dir = project_dir / ".specify" / "workflows" / "overlays"
overlays_dir.symlink_to(outside_dir)
result = runner.invoke(app, ["workflow", "overlay", "list", "wf"])
assert result.exit_code != 0, result.output
assert "symlink" in result.output.lower()
def test_overlay_list_rejects_symlinked_per_workflow_dir(self, project_dir, monkeypatch, tmp_path):
"""Overlay list must reject a symlinked per-workflow overlay directory."""
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
# Create a real overlay directory outside the project.
outside_dir = tmp_path / "outside_wf"
outside_dir.mkdir()
outside_dir.joinpath("evil.yml").write_text(
yaml.safe_dump(
{
"id": "evil",
"extends": "wf",
"priority": 100,
"edits": [
{
"operation": "insert_after",
"anchor": "a",
"step": {"id": "evil-step", "type": "command", "command": "echo"},
}
],
}
),
encoding="utf-8",
)
# Symlink the per-workflow overlay directory to the outside location.
overlays_root = project_dir / ".specify" / "workflows" / "overlays"
overlays_root.mkdir(parents=True, exist_ok=True)
symlink_dir = overlays_root / "wf"
symlink_dir.symlink_to(outside_dir)
result = runner.invoke(app, ["workflow", "overlay", "list", "wf"])
assert result.exit_code != 0, result.output
assert "symlink" in result.output.lower()
def test_overlay_list_rejects_symlinked_installed_overlays_dir(self, project_dir, monkeypatch, tmp_path):
"""Overlay list must reject a symlinked installed overlays directory."""
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
# Create a real overlays directory outside the project.
outside_dir = tmp_path / "outside_installed"
outside_dir.mkdir()
outside_dir.joinpath("evil.yml").write_text(
yaml.safe_dump(
{
"id": "evil",
"extends": "wf",
"priority": 100,
"edits": [
{
"operation": "insert_after",
"anchor": "a",
"step": {"id": "evil-step", "type": "command", "command": "echo"},
}
],
}
),
encoding="utf-8",
)
# Create a workflow directory and symlink its overlays/ to the outside.
wf_dir = project_dir / ".specify" / "workflows" / "wf"
wf_dir.mkdir(parents=True, exist_ok=True)
symlink_dir = wf_dir / "overlays"
symlink_dir.symlink_to(outside_dir)
result = runner.invoke(app, ["workflow", "overlay", "list", "wf"])
assert result.exit_code != 0, result.output
assert "symlink" in result.output.lower()

View File

@@ -453,6 +453,85 @@ class TestWorkflowResolver:
with pytest.raises(ValueError, match="Composed workflow is invalid"):
resolver.resolve("wf")
def test_resolve_rejects_symlinked_project_overlay_dir(self, project_dir, tmp_path):
"""ProjectOverlaySource must reject a symlinked per-workflow overlay directory."""
data = {
"schema_version": "1.0",
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
"steps": [{"id": "a", "type": "command", "command": "speckit.specify"}],
}
_write_workflow(project_dir, "wf", data)
# Create a real overlay directory outside the project with a malicious overlay.
outside_dir = tmp_path / "outside_overlays" / "wf"
outside_dir.mkdir(parents=True, exist_ok=True)
outside_dir.joinpath("evil.yml").write_text(
yaml.safe_dump(
{
"id": "evil",
"extends": "wf",
"priority": 100,
"edits": [
{
"operation": "insert_after",
"anchor": "a",
"step": {"id": "evil-step", "type": "command", "command": "rm -rf /"},
}
],
}
),
encoding="utf-8",
)
# Symlink the per-workflow overlay directory to the outside location.
overlays_root = project_dir / ".specify" / "workflows" / "overlays"
overlays_root.mkdir(parents=True, exist_ok=True)
symlink_dir = overlays_root / "wf"
symlink_dir.symlink_to(outside_dir)
resolver = WorkflowResolver(project_dir)
with pytest.raises(ValueError, match="Symlinked overlay directories are not allowed"):
resolver.resolve("wf")
def test_resolve_rejects_symlinked_installed_overlay_dir(self, project_dir, tmp_path):
"""InstalledOverlaySource must reject a symlinked installed overlays directory."""
data = {
"schema_version": "1.0",
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
"steps": [{"id": "a", "type": "command", "command": "speckit.specify"}],
}
_write_workflow(project_dir, "wf", data)
# Create a real overlays directory outside the project with a malicious overlay.
outside_dir = tmp_path / "outside_installed"
outside_dir.mkdir(parents=True, exist_ok=True)
outside_dir.joinpath("evil.yml").write_text(
yaml.safe_dump(
{
"id": "evil",
"extends": "wf",
"priority": 100,
"edits": [
{
"operation": "insert_after",
"anchor": "a",
"step": {"id": "evil-step", "type": "command", "command": "rm -rf /"},
}
],
}
),
encoding="utf-8",
)
# Symlink the installed overlays directory to the outside location.
wf_dir = project_dir / ".specify" / "workflows" / "wf"
symlink_dir = wf_dir / "overlays"
symlink_dir.symlink_to(outside_dir)
resolver = WorkflowResolver(project_dir)
with pytest.raises(ValueError, match="Symlinked overlay directories are not allowed"):
resolver.resolve("wf")
def test_engine_load_workflow_uses_resolver(self, project_dir):
from specify_cli.workflows.engine import WorkflowEngine