build: reject ambiguous checkpoint archives

This commit is contained in:
Codex 2026-10-21 21:45:37 +02:00
parent 7b817f6339
commit 7131b50e2e
5 changed files with 90 additions and 12 deletions

View file

@ -18,7 +18,8 @@ The repository now provides:
toolchain, rejects modified upstream source and replaces only the validated toolchain, rejects modified upstream source and replaces only the validated
addon staging directory; addon staging directory;
- a non-executing verifier for externally produced Windows artifacts; - a non-executing verifier for externally produced Windows artifacts;
it requires an exact repository-to-manifest-to-ZIP addon file set; it requires an exact repository-to-manifest-to-ZIP addon file set and
rejects unsafe, colliding, encrypted or linked archive entries;
- a checksummed acceptance-kit generator containing only allowlisted public - a checksummed acceptance-kit generator containing only allowlisted public
synthetic inputs, with provenance binding to the repository commit and synthetic inputs, with provenance binding to the repository commit and
non-executing artifact-verification report, including the Rayforge pin and non-executing artifact-verification report, including the Rayforge pin and

View file

@ -60,7 +60,9 @@ sizes and SHA-256 digests, rejects unsafe ZIP paths, confirms the pinned
Rayforge commit, requires the manifest and bundle to contain exactly the Rayforge commit, requires the manifest and bundle to contain exactly the
versioned repository addon file set with matching digests, and requires the versioned repository addon file set with matching digests, and requires the
pinned Rayforge license both inside the ZIP and as a sidecar. Missing, changed pinned Rayforge license both inside the ZIP and as a sidecar. Missing, changed
or additional addon files fail verification. or additional addon files fail verification. Archive entries also fail on
duplicate or case-colliding Windows paths, backslashes, drive-like paths,
encryption or symbolic links.
The schema-1 verification output records the exact checkpoint-manifest name The schema-1 verification output records the exact checkpoint-manifest name
and SHA-256 plus the fixed checkpoint and addon identities, so later manual and SHA-256 plus the fixed checkpoint and addon identities, so later manual
acceptance can retain the complete artifact provenance chain. acceptance can retain the complete artifact provenance chain.

View file

@ -328,7 +328,7 @@ An artifact produced elsewhere can be checked locally without execution: the
verifier rejects unsafe paths or changed bytes and confirms the pin plus every verifier rejects unsafe paths or changed bytes and confirms the pin plus every
packaged addon source before the manual GUI checklist may begin. It also packaged addon source before the manual GUI checklist may begin. It also
rejects omitted manifest entries and extra files hidden in the packaged addon rejects omitted manifest entries and extra files hidden in the packaged addon
directory. directory, plus ambiguous or unsafe Windows ZIP entries.
The manual checklist now also has a versioned, fail-closed report template and The manual checklist now also has a versioned, fail-closed report template and
validator. Missing steps remain incomplete, a complete operator run remains validator. Missing steps remain incomplete, a complete operator run remains
ready for review until a named, timezone-stamped human review is recorded, ready for review until a named, timezone-stamped human review is recorded,

View file

@ -6,6 +6,7 @@ import argparse
import hashlib import hashlib
import json import json
import re import re
import stat
import zipfile import zipfile
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
@ -63,6 +64,35 @@ def _relative_below(path: PurePosixPath, root: PurePosixPath) -> PurePosixPath |
return None return None
def _safe_archive_files(archive: zipfile.ZipFile) -> dict[PurePosixPath, zipfile.ZipInfo]:
files = {}
seen = set()
for info in archive.infolist():
name = info.filename
path = PurePosixPath(name)
canonical = name.rstrip("/").casefold()
if (
not name
or "\\" in name
or "\x00" in name
or path.is_absolute()
or ".." in path.parts
or (path.parts and ":" in path.parts[0])
):
raise ValueError("bundle contains an unsafe path")
if not canonical or canonical in seen:
raise ValueError("bundle contains a duplicate or case-colliding path")
seen.add(canonical)
if info.flag_bits & 0x1:
raise ValueError("bundle contains an encrypted entry")
mode = (info.external_attr >> 16) & 0xFFFF
if stat.S_ISLNK(mode):
raise ValueError("bundle contains a symbolic link")
if not info.is_dir():
files[path] = info
return files
def verify(manifest_path: Path) -> dict: def verify(manifest_path: Path) -> dict:
manifest_path = manifest_path.resolve() manifest_path = manifest_path.resolve()
manifest_bytes = manifest_path.read_bytes() manifest_bytes = manifest_path.read_bytes()
@ -112,14 +142,7 @@ def verify(manifest_path: Path) -> dict:
raise ValueError(f"checkpoint addon source differs from repository: {relative}") raise ValueError(f"checkpoint addon source differs from repository: {relative}")
with zipfile.ZipFile(bundle) as archive: with zipfile.ZipFile(bundle) as archive:
files = { files = _safe_archive_files(archive)
PurePosixPath(info.filename): info
for info in archive.infolist()
if not info.is_dir()
}
for path in files:
if path.is_absolute() or ".." in path.parts:
raise ValueError("bundle contains an unsafe path")
executable = manifest["bundle"]["path"].removesuffix(".zip") + ".exe" executable = manifest["bundle"]["path"].removesuffix(".zip") + ".exe"
if not any(path.name == executable for path in files): if not any(path.name == executable for path in files):
raise ValueError("bundle application executable is missing") raise ValueError("bundle application executable is missing")

View file

@ -1,14 +1,18 @@
import hashlib import hashlib
import json import json
import stat
import tempfile import tempfile
import unittest import unittest
import warnings
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from scripts.verify_rayforge_windows_checkpoint import ( from scripts.verify_rayforge_windows_checkpoint import (
ADDON_SOURCE, ADDON_SOURCE,
ROOT, ROOT,
UPSTREAM, UPSTREAM,
_safe_archive_files,
verify, verify,
) )
@ -22,6 +26,9 @@ class WindowsCheckpointVerifierTests(unittest.TestCase):
root: Path, root: Path,
unsafe_member: str | None = None, unsafe_member: str | None = None,
extra_addon_file: str | None = None, extra_addon_file: str | None = None,
duplicate_member: bool = False,
case_collision: bool = False,
symlink_member: bool = False,
) -> Path: ) -> Path:
bundle_name = f"rayforge-vpirateray-{UPSTREAM['commit'][:12]}.zip" bundle_name = f"rayforge-vpirateray-{UPSTREAM['commit'][:12]}.zip"
executable = bundle_name.removesuffix(".zip") + ".exe" executable = bundle_name.removesuffix(".zip") + ".exe"
@ -50,7 +57,26 @@ class WindowsCheckpointVerifierTests(unittest.TestCase):
license_data, license_data,
) )
if unsafe_member: if unsafe_member:
archive.writestr(unsafe_member, b"unsafe") unsafe = zipfile.ZipInfo("placeholder")
unsafe.filename = unsafe_member
unsafe.orig_filename = unsafe_member
archive.writestr(unsafe, b"unsafe")
if duplicate_member:
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
archive.writestr(
f"{bundle_name[:-4]}/{executable}", b"duplicate"
)
if case_collision:
archive.writestr(
f"{bundle_name[:-4].upper()}/{executable.upper()}",
b"case collision",
)
if symlink_member:
link = zipfile.ZipInfo(f"{bundle_name[:-4]}/link")
link.create_system = 3
link.external_attr = (stat.S_IFLNK | 0o777) << 16
archive.writestr(link, "target")
installer = root / f"{bundle_name[:-4]}-installer.exe" installer = root / f"{bundle_name[:-4]}-installer.exe"
installer.write_bytes(b"synthetic installer, never executed") installer.write_bytes(b"synthetic installer, never executed")
license_sidecar = root / "Rayforge-LICENSE.txt" license_sidecar = root / "Rayforge-LICENSE.txt"
@ -118,6 +144,32 @@ class WindowsCheckpointVerifierTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "unsafe path"): with self.assertRaisesRegex(ValueError, "unsafe path"):
verify(manifest) verify(manifest)
def test_windows_unsafe_and_colliding_archive_paths_are_rejected(self):
unsafe = SimpleNamespace(
filename="folder\\escape.txt",
flag_bits=0,
external_attr=0,
is_dir=lambda: False,
)
archive = SimpleNamespace(infolist=lambda: [unsafe])
with self.assertRaisesRegex(ValueError, "unsafe path"):
_safe_archive_files(archive)
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
manifest = self._checkpoint(Path(directory), duplicate_member=True)
with self.assertRaisesRegex(ValueError, "duplicate or case-colliding"):
verify(manifest)
manifest = self._checkpoint(Path(directory), case_collision=True)
with self.assertRaisesRegex(ValueError, "duplicate or case-colliding"):
verify(manifest)
def test_symbolic_link_archive_entry_is_rejected(self):
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
manifest = self._checkpoint(Path(directory), symlink_member=True)
with self.assertRaisesRegex(ValueError, "symbolic link"):
verify(manifest)
def test_manifest_cannot_reference_external_artifacts(self): def test_manifest_cannot_reference_external_artifacts(self):
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
manifest = self._checkpoint(Path(directory)) manifest = self._checkpoint(Path(directory))