Files
github--spec-kit/tests/integration/test_bundler_security_paths.py
T
2026-07-13 11:57:40 +08:00

193 lines
6.5 KiB
Python

"""Security tests: path-traversal / symlink confinement (Constitution Principle V).
These assert the bundler refuses to read or write outside an allowed root, so a
malicious manifest or artifact path cannot escape the project/bundle directory.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from specify_cli.bundler import BundlerError
from specify_cli.bundler.lib.yamlio import ensure_within, is_safe_relpath
def test_ensure_within_allows_child(tmp_path: Path):
root = tmp_path / "bundle"
root.mkdir()
child = root / "sub" / "file.txt"
assert ensure_within(root, child) == child.resolve()
def test_ensure_within_rejects_parent_traversal(tmp_path: Path):
root = tmp_path / "bundle"
root.mkdir()
escape = root / ".." / "secret.txt"
with pytest.raises(BundlerError, match="escapes"):
ensure_within(root, escape)
def test_ensure_within_rejects_absolute_outside(tmp_path: Path):
root = tmp_path / "bundle"
root.mkdir()
with pytest.raises(BundlerError):
ensure_within(root, Path("/etc/passwd"))
@pytest.mark.skipif(os.name == "nt", reason="symlink semantics differ on Windows")
def test_ensure_within_rejects_symlink_escape(tmp_path: Path):
root = tmp_path / "bundle"
root.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("secret", encoding="utf-8")
link = root / "link.txt"
link.symlink_to(outside)
with pytest.raises(BundlerError, match="escapes"):
ensure_within(root, link)
@pytest.mark.parametrize("rel,safe", [
("a/b.txt", True),
("./a.txt", True),
("../escape", False),
("a/../../escape", False),
("/abs", False),
("C:/abs", False),
("C:\\abs", False),
("\\\\server\\share", False),
("", False),
])
def test_is_safe_relpath(rel, safe):
assert is_safe_relpath(rel) is safe
def test_build_skips_symlinks(tmp_path: Path):
"""Packager must not follow symlinks out of the bundle dir."""
import yaml
from specify_cli.bundler.services.packager import build_bundle
from tests.bundler_helpers import valid_manifest_dict
bundle = tmp_path / "bundle"
bundle.mkdir()
(bundle / "bundle.yml").write_text(
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
)
(bundle / "README.md").write_text("# Demo", encoding="utf-8")
if os.name != "nt":
secret = tmp_path / "secret.txt"
secret.write_text("top secret", encoding="utf-8")
(bundle / "leak.txt").symlink_to(secret)
result = build_bundle(bundle, output_dir=tmp_path / "out")
import zipfile
with zipfile.ZipFile(result.artifact_path) as archive:
names = archive.namelist()
assert "leak.txt" not in names
assert "bundle.yml" in names
def test_load_records_refuses_symlinked_specify_escape(tmp_path: Path):
# Reading bundle-records.json must honour the same confinement as writes:
# a symlinked .specify pointing outside project_root is refused.
from specify_cli.bundler.models.records import load_records
project = tmp_path / "proj"
project.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
(outside / "bundle-records.json").write_text(
'{"schema_version": "1.0", "bundles": []}', encoding="utf-8"
)
(project / ".specify").symlink_to(outside, target_is_directory=True)
with pytest.raises(BundlerError, match="escapes the allowed root"):
load_records(project)
def test_active_integration_refuses_symlinked_specify_escape(tmp_path: Path):
# Reading the integration marker must not follow a .specify symlink that
# resolves outside project_root; an escape is treated as "not determinable".
from specify_cli.bundler.lib.project import active_integration
project = tmp_path / "proj"
project.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
(outside / "integration.json").write_text(
'{"integration": "leaked"}', encoding="utf-8"
)
(project / ".specify").symlink_to(outside, target_is_directory=True)
assert active_integration(project) is None
def test_read_catalog_config_refuses_symlinked_specify_escape(tmp_path: Path):
from specify_cli.bundler.commands_impl import catalog_config as cc
project = tmp_path / "proj"
project.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
(outside / "bundle-catalogs.yml").write_text(
"schema_version: '1.0'\ncatalogs: []\n", encoding="utf-8"
)
(project / ".specify").symlink_to(outside, target_is_directory=True)
with pytest.raises(BundlerError, match="escapes the allowed root"):
cc._read(project)
def test_load_source_stack_refuses_symlinked_specify_dir(tmp_path: Path):
from specify_cli.bundler.models.catalog import load_source_stack
project = tmp_path / "project"
project.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
(outside / "bundle-catalogs.yml").write_text("catalogs: []\n", encoding="utf-8")
try:
(project / ".specify").symlink_to(outside, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlinks not supported on this platform")
with pytest.raises(BundlerError, match="escapes the allowed root"):
load_source_stack(project)
def test_find_project_root_ignores_symlinked_specify(tmp_path: Path):
from specify_cli.bundler.lib.project import find_project_root
real = tmp_path / "real-specify"
real.mkdir()
project = tmp_path / "project"
project.mkdir()
try:
(project / ".specify").symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlinks not supported on this platform")
# A symlinked .specify must not be accepted as a project root.
assert find_project_root(project) is None
def test_find_project_root_override_errors_on_symlinked_specify(tmp_path: Path, monkeypatch):
"""The SPECIFY_INIT_DIR override path refuses a symlinked .specify too,
matching the cwd loop path (regression: the override returned early and
skipped the symlink guard)."""
from specify_cli.bundler.lib.project import find_project_root
real = tmp_path / "real-specify"
real.mkdir()
project = tmp_path / "project"
project.mkdir()
try:
(project / ".specify").symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlinks not supported on this platform")
monkeypatch.setenv("SPECIFY_INIT_DIR", str(project))
with pytest.raises(BundlerError, match="symlinked \\.specify"):
find_project_root(None)