fix(site-publish): reject split input symlinks
Test / contract (pull_request) Successful in 6s

Authored-By: OpenAI (GPT-5) <noreply@openai.com>
This commit is contained in:
Evelyn Chen
2026-08-29 22:30:43 +00:00
parent fb2e440bbb
commit 7b824a61ca
3 changed files with 23 additions and 2 deletions
+3 -2
View File
@@ -136,8 +136,9 @@ provisional cache policy or a pointer to a missing immutable target.
Artifact input directories must be pairwise disjoint after filesystem Artifact input directories must be pairwise disjoint after filesystem
resolution. Publication stops before build or upload if one contains another or resolution. Publication stops before build or upload if one contains another or
escapes the repository, preventing protected input from entering a public escapes the repository. Descendant symlinks are also rejected, preventing
artifact. Split storage endpoints are pinned to Garage, and each website protected input from entering a public artifact through dereference. Split
storage endpoints are pinned to Garage, and each website
authority is derived from its bucket; a site cannot expose an arbitrary backend. authority is derived from its bucket; a site cannot expose an arbitrary backend.
Each split route gets a bucket-specific `<bucket>.web.sjc001.fritzlab.net` Each split route gets a bucket-specific `<bucket>.web.sjc001.fritzlab.net`
+9
View File
@@ -427,6 +427,8 @@ def normalize_site_config(raw, site_name):
def validate_artifact_inputs(site_dir, cfg): def validate_artifact_inputs(site_dir, cfg):
"""Reject source containment before a public or protected build starts.""" """Reject source containment before a public or protected build starts."""
if cfg["compatibility"]:
return
root = Path(site_dir).resolve() root = Path(site_dir).resolve()
sources = [] sources = []
for artifact in cfg["artifacts"]: for artifact in cfg["artifacts"]:
@@ -435,6 +437,13 @@ def validate_artifact_inputs(site_dir, cfg):
raise ConfigError( raise ConfigError(
f"artifact {artifact['name']} content_dir resolves outside the repository" f"artifact {artifact['name']} content_dir resolves outside the repository"
) )
if source.exists():
symlink = next((path for path in source.rglob("*") if path.is_symlink()), None)
if symlink is not None:
raise ConfigError(
f"artifact {artifact['name']} build input contains symlink: "
f"{symlink.relative_to(root)}"
)
sources.append((artifact["name"], source)) sources.append((artifact["name"], source))
for index, (name, source) in enumerate(sources): for index, (name, source) in enumerate(sources):
for other_name, other_source in sources[index + 1:]: for other_name, other_source in sources[index + 1:]:
+11
View File
@@ -219,6 +219,17 @@ class ConfigContractTests(unittest.TestCase):
with self.assertRaisesRegex(ConfigError, "build inputs overlap after resolution"): with self.assertRaisesRegex(ConfigError, "build inputs overlap after resolution"):
validate_artifact_inputs(root, cfg) validate_artifact_inputs(root, cfg)
def test_descendant_symlink_cannot_cross_artifact_boundary(self):
cfg = normalize_site_config(self.raw, "baseline.fritzlab.net")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "dist").mkdir()
(root / "portal" / "build").mkdir(parents=True)
(root / "portal" / "build" / "private.txt").write_text("private")
(root / "dist" / "portal-link").symlink_to(root / "portal" / "build")
with self.assertRaisesRegex(ConfigError, "build input contains symlink"):
validate_artifact_inputs(root, cfg)
class GenerationTests(unittest.TestCase): class GenerationTests(unittest.TestCase):
def render(self, raw): def render(self, raw):