fix: harden runtime config snapshot publication
This commit is contained in:
@@ -26,22 +26,41 @@ def safe_rev(v: str) -> bool:
|
||||
|
||||
|
||||
def open_dir(parent: int | None, name: str, create: bool = False) -> int:
|
||||
# Darwin rejects O_NOFOLLOW|openat for directories (ELOOP); lstat the
|
||||
# component before opening and verify the resulting descriptor below. Linux
|
||||
# uses the stronger flag where available.
|
||||
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
||||
if sys.platform != "darwin":
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
entry = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
||||
if stat.S_ISLNK(entry.st_mode):
|
||||
fail("runtime config directory is not trusted")
|
||||
return os.open(name, flags, dir_fd=parent)
|
||||
except FileNotFoundError:
|
||||
if not create:
|
||||
raise
|
||||
os.mkdir(name, 0o700, dir_fd=parent)
|
||||
return os.open(name, flags, dir_fd=parent)
|
||||
"""Open one directory component without following a replaced entry.
|
||||
|
||||
The pre-open lstat and post-open fstat identity check is required on Darwin,
|
||||
where O_NOFOLLOW has historically been unavailable for directory openat.
|
||||
mkdir races are resolved by opening and validating the winner.
|
||||
"""
|
||||
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW
|
||||
while True:
|
||||
try:
|
||||
entry = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
||||
if stat.S_ISLNK(entry.st_mode):
|
||||
fail("runtime config directory is not trusted")
|
||||
fd = os.open(name, flags, dir_fd=parent)
|
||||
try:
|
||||
opened = os.fstat(fd)
|
||||
if (opened.st_dev != entry.st_dev or opened.st_ino != entry.st_ino
|
||||
or not stat.S_ISDIR(opened.st_mode)):
|
||||
fail("runtime config directory changed during open")
|
||||
return fd
|
||||
except BaseException:
|
||||
os.close(fd)
|
||||
raise
|
||||
except FileNotFoundError:
|
||||
if not create:
|
||||
raise
|
||||
try:
|
||||
os.mkdir(name, 0o700, dir_fd=parent)
|
||||
except FileExistsError:
|
||||
# Another publisher won creation. Re-enter the identity-checked
|
||||
# open path instead of exposing EEXIST to the caller.
|
||||
continue
|
||||
if parent is not None:
|
||||
os.fsync(parent)
|
||||
# Re-open through the same no-follow and identity checks.
|
||||
continue
|
||||
|
||||
|
||||
def checked_dir(fd: int, expected_mode: int = 0o700) -> None:
|
||||
@@ -141,8 +160,9 @@ def strict_manifest(value: object) -> dict:
|
||||
fail("runtime config manifest is invalid")
|
||||
required = {
|
||||
"version", "workspace_id", "workspace_revision", "descriptor_git_blob",
|
||||
"descriptor_sha256", "config_sha256", "config_dwh_binding", "config_dev",
|
||||
"config_ino", "config_size", "config_mode", "config_uid", "config_nlink",
|
||||
"descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_sha256",
|
||||
"config_dwh_binding", "config_dev", "config_ino", "config_size", "config_mode",
|
||||
"config_uid", "config_nlink",
|
||||
}
|
||||
if set(value) != required or value.get("version") != 1:
|
||||
fail("runtime config manifest is invalid")
|
||||
@@ -156,7 +176,7 @@ def strict_manifest(value: object) -> dict:
|
||||
binding_value = value.get("config_dwh_binding")
|
||||
if not isinstance(binding_value, dict) or set(binding_value) != {"workspace_id", "config_fingerprint", "input_fingerprint"} or any(not isinstance(x, str) for x in binding_value.values()):
|
||||
fail("runtime config manifest is invalid")
|
||||
for key in ("config_dev", "config_ino", "config_size", "config_uid", "config_nlink"):
|
||||
for key in ("descriptor_dev", "descriptor_ino", "config_dev", "config_ino", "config_size", "config_uid", "config_nlink"):
|
||||
if not isinstance(value[key], str) or not value[key].isdigit():
|
||||
fail("runtime config manifest is invalid")
|
||||
if value["config_mode"] != "400":
|
||||
@@ -193,19 +213,10 @@ def publish(inp: dict) -> dict:
|
||||
checked_dir(cfgdir)
|
||||
mandir = open_dir(prep, "runtime-config-manifests", True)
|
||||
checked_dir(mandir)
|
||||
lockfd = os.open(
|
||||
"runtime-config.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600, dir_fd=prep
|
||||
)
|
||||
# The retained preprocessing directory is the single cross-process lock seam.
|
||||
# No pathname lock file is created in the workspace layout.
|
||||
fcntl.flock(prep, fcntl.LOCK_EX)
|
||||
try:
|
||||
ls = os.fstat(lockfd)
|
||||
if (
|
||||
not stat.S_ISREG(ls.st_mode)
|
||||
or ls.st_nlink != 1
|
||||
or stat.S_IMODE(ls.st_mode) != 0o600
|
||||
or ls.st_uid != os.getuid()
|
||||
):
|
||||
fail("runtime config lock is not trusted")
|
||||
fcntl.flock(lockfd, fcntl.LOCK_EX)
|
||||
name = f"{rev}.yaml"
|
||||
mname = f"{rev}.json"
|
||||
|
||||
@@ -302,9 +313,25 @@ def publish(inp: dict) -> dict:
|
||||
write_all(fd, mb)
|
||||
os.fchmod(fd, 0o600)
|
||||
os.fsync(fd)
|
||||
os.link(stage, mname, src_dir_fd=mandir, dst_dir_fd=mandir, follow_symlinks=False)
|
||||
except FileExistsError:
|
||||
pass
|
||||
try:
|
||||
os.link(stage, mname, src_dir_fd=mandir, dst_dir_fd=mandir, follow_symlinks=False)
|
||||
except FileExistsError:
|
||||
# A no-replace loser is successful only after validating the
|
||||
# durable winner byte-for-byte and against the strict schema.
|
||||
winner = current(mandir, mname, 0o600)
|
||||
if winner is None:
|
||||
fail("runtime config manifest publication raced")
|
||||
wfd, _ = winner
|
||||
try:
|
||||
existing = read_all(wfd)
|
||||
finally:
|
||||
os.close(wfd)
|
||||
try:
|
||||
strict_manifest(json.loads(existing.decode()))
|
||||
except (ValueError, TypeError, UnicodeError, RuntimeError):
|
||||
fail("runtime config manifest is invalid")
|
||||
if existing != mb:
|
||||
fail("same-revision runtime configuration changed")
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
@@ -321,7 +348,6 @@ def publish(inp: dict) -> dict:
|
||||
"ino": s.st_ino,
|
||||
}
|
||||
finally:
|
||||
os.close(lockfd)
|
||||
os.close(cfgdir)
|
||||
os.close(mandir)
|
||||
os.close(prep)
|
||||
@@ -346,7 +372,7 @@ def verified_snapshot(inp: dict) -> dict:
|
||||
checked_dir(rdir)
|
||||
fd = os.open(f"{wid}.yaml", os.O_RDONLY | os.O_NOFOLLOW, dir_fd=rdir)
|
||||
try:
|
||||
read_regular(fd, 0o400)
|
||||
descriptor_info = read_regular(fd, 0o400)
|
||||
chunks = []
|
||||
while True:
|
||||
x = os.read(fd, 1024 * 1024)
|
||||
@@ -375,16 +401,39 @@ def verified_snapshot(inp: dict) -> dict:
|
||||
fail("workspace snapshot integrity check failed")
|
||||
records = manifest.get("revisions")
|
||||
files = manifest.get("files")
|
||||
record = next((r for r in records if isinstance(r, dict) and r.get("id") == wid), None) if isinstance(records, list) else None
|
||||
if not isinstance(records, list) or not isinstance(files, dict) or not records:
|
||||
fail("workspace snapshot integrity check failed")
|
||||
record_by_id: dict[str, dict] = {}
|
||||
for item in records:
|
||||
if not isinstance(item, dict) or set(item) != {"id", "commit", "blob", "snapshotPath"}:
|
||||
fail("workspace snapshot integrity check failed")
|
||||
item_id = item.get("id")
|
||||
if not isinstance(item_id, str) or not safe_id(item_id) or item_id in record_by_id:
|
||||
fail("workspace snapshot integrity check failed")
|
||||
if item.get("commit") != rev or item.get("snapshotPath") != f"{root}/{rev}/{item_id}.yaml":
|
||||
fail("workspace snapshot integrity check failed")
|
||||
if not isinstance(item.get("blob"), str) or not safe_rev(item["blob"]):
|
||||
fail("workspace snapshot integrity check failed")
|
||||
record_by_id[item_id] = item
|
||||
expected_names = {name for item_id in record_by_id for name in (f"{item_id}.yaml", f"{item_id}.env.example", f"{item_id}.md")}
|
||||
if set(files) != expected_names or any(not isinstance(v, str) or not __import__("re").fullmatch(r"[0-9a-f]{64}", v) for v in files.values()):
|
||||
fail("workspace snapshot integrity check failed")
|
||||
# Verify every immutable file declared by snapshot.json, not just the selected
|
||||
# descriptor. This prevents extra records/files from smuggling a second state.
|
||||
for filename in sorted(expected_names):
|
||||
f = os.open(filename, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=rdir)
|
||||
try:
|
||||
read_regular(f, 0o400)
|
||||
actual = hashlib.sha256(read_all(f)).hexdigest()
|
||||
finally:
|
||||
os.close(f)
|
||||
if actual != files[filename]:
|
||||
fail("workspace snapshot integrity check failed")
|
||||
record = record_by_id.get(wid)
|
||||
expected_path = f"{root}/{rev}/{wid}.yaml"
|
||||
if (
|
||||
manifest.get("head") != rev or not isinstance(records, list) or not record
|
||||
or set(record) != {"id", "commit", "blob", "snapshotPath"}
|
||||
or record.get("commit") != rev or record.get("snapshotPath") != expected_path
|
||||
or not isinstance(record.get("blob"), str) or not safe_rev(record.get("blob"))
|
||||
or not isinstance(files, dict)
|
||||
or files.get(f"{wid}.yaml") != hashlib.sha256(source).hexdigest()
|
||||
):
|
||||
if record is None or manifest.get("head") != rev or record.get("snapshotPath") != expected_path:
|
||||
fail("workspace snapshot integrity check failed")
|
||||
if files.get(f"{wid}.yaml") != hashlib.sha256(source).hexdigest():
|
||||
fail("workspace snapshot integrity check failed")
|
||||
repo = inp.get("repository_root")
|
||||
if not isinstance(repo, str) or not os.path.isabs(repo):
|
||||
@@ -401,25 +450,20 @@ def verified_snapshot(inp: dict) -> dict:
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
fail("workspace Git revision is unavailable")
|
||||
try:
|
||||
import re
|
||||
from collections import Counter
|
||||
normalize = lambda value: re.findall(r"[A-Za-z0-9_.:/@+-]+", value)
|
||||
git_tokens = Counter(normalize(git_source.decode("utf-8")))
|
||||
snapshot_tokens = Counter(normalize(source.decode("utf-8")))
|
||||
# The registry canonicalizer may add schema defaults/reorder mappings.
|
||||
# Every token from the exact Git descriptor must nevertheless survive;
|
||||
# replacements (including non-rendered workspace.name) are rejected.
|
||||
equivalent = all(snapshot_tokens[k] >= count for k, count in git_tokens.items())
|
||||
git_text = git_source.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
equivalent = False
|
||||
if blob != record.get("blob") or not equivalent:
|
||||
fail("workspace Git descriptor identity mismatch")
|
||||
if blob != record.get("blob"):
|
||||
fail("workspace Git descriptor identity mismatch")
|
||||
return {
|
||||
"workspace_id": wid,
|
||||
"workspace_revision": rev,
|
||||
"source": source.decode(),
|
||||
"git_source": git_text,
|
||||
"sha256": hashlib.sha256(source).hexdigest(),
|
||||
"descriptor_git_blob": record.get("blob"),
|
||||
"descriptor_dev": descriptor_info.st_dev,
|
||||
"descriptor_ino": descriptor_info.st_ino,
|
||||
"snapshot_path": f"{root}/{rev}/{wid}.yaml",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user