fix: harden runtime config snapshot publication

This commit is contained in:
2026-08-11 10:50:21 +02:00
parent 971315aa80
commit e9613767c5
5 changed files with 280 additions and 101 deletions
+92 -11
View File
@@ -3,6 +3,7 @@ import json
import os
import re
import stat
import sys
import warnings
from ipaddress import ip_address
from pathlib import Path
@@ -596,8 +597,9 @@ def _read_runtime_fd(fd: int, label: str, expected_mode: int = 0o400) -> tuple[b
def _strict_runtime_manifest(raw: object) -> dict[str, object]:
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 not isinstance(raw, dict) or set(raw) != required or raw.get("version") != 1:
raise ConfigError("Manifest runtime non valido")
@@ -609,7 +611,7 @@ def _strict_runtime_manifest(raw: object) -> dict[str, object]:
for key in ("descriptor_sha256", "config_sha256"):
if not isinstance(raw[key], str) or not re.fullmatch(r"[0-9a-f]{64}", raw[key]):
raise ConfigError("Manifest runtime non valido")
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(raw[key], str) or not raw[key].isdigit():
raise ConfigError("Manifest runtime non valido")
if raw["config_mode"] != "400":
@@ -617,22 +619,100 @@ def _strict_runtime_manifest(raw: object) -> dict[str, object]:
return raw
def _open_runtime_component(parent: int, name: str) -> int:
before = os.stat(name, dir_fd=parent, follow_symlinks=False)
if stat.S_ISLNK(before.st_mode):
raise OSError("runtime config path contains a symlink")
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW
fd = os.open(name, flags, dir_fd=parent)
after = os.fstat(fd)
if (before.st_dev != after.st_dev or before.st_ino != after.st_ino
or not stat.S_ISDIR(after.st_mode)):
os.close(fd)
raise OSError("runtime config path changed during open")
return fd
def _open_runtime_file(path: Path, expected_mode: int) -> int:
if not path.is_absolute():
raise OSError("runtime config path must be absolute")
parts = list(path.parts)
if sys.platform == "darwin" and len(parts) > 1 and parts[1] in ("var", "tmp"):
parts = ["/", "private", *parts[1:]]
if not parts or parts[0] != "/" or any(part in ("", ".", "..") or "/" in part for part in parts[1:]):
raise OSError("runtime config path is invalid")
current = os.open("/", os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
try:
for component in parts[1:-1]:
nxt = _open_runtime_component(current, component)
os.close(current)
current = nxt
fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW, dir_fd=current)
info = os.fstat(fd)
if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1
or stat.S_IMODE(info.st_mode) != expected_mode or info.st_uid != os.getuid()):
os.close(fd)
raise OSError("unsafe runtime file")
return fd
finally:
os.close(current)
def _runtime_manifest_path(config_path: Path) -> Path:
if config_path.name == "" or config_path.suffix != ".yaml" or config_path.parent.name != "runtime-config":
raise OSError("runtime config path is not canonical")
if not re.fullmatch(r"[0-9a-f]{40}", config_path.stem):
raise OSError("runtime config path is not canonical")
return config_path.parent.parent / "runtime-config-manifests" / f"{config_path.stem}.json"
def load_config(path: Path) -> Config:
# Backend runtime leases pass the verified canonical config as fd 3 while retaining
# the ordinary absolute -c argument for diagnostics and source identity. Never reopen
# that pathname: an ancestor or leaf replacement after spawn must not alter bytes used
# by the harness.
# Registry leases authenticate the canonical pathname through a durable manifest
# digest. The config and manifest are opened component-by-component; FD 3/4 are
# reserved for the maintenance writer/root ABI.
runtime_manifest: dict[str, object] | None = None
expected_manifest = os.environ.get("THT_RUNTIME_CONFIG_MANIFEST_SHA256")
runtime_fd = os.environ.get("THT_CONFIG_FD")
manifest_fd = os.environ.get("THT_CONFIG_MANIFEST_FD")
expected_manifest = os.environ.get("THT_CONFIG_MANIFEST_SHA256")
if runtime_fd is not None or manifest_fd is not None or expected_manifest is not None:
if runtime_fd is None or manifest_fd is None or expected_manifest is None or not re.fullmatch(r"[0-9a-f]{64}", expected_manifest):
legacy_expected = os.environ.get("THT_CONFIG_MANIFEST_SHA256")
if expected_manifest is not None:
if not re.fullmatch(r"[0-9a-f]{64}", expected_manifest):
raise ConfigError("Handoff runtime incompleto")
config_fd = manifest_fd_local = None
try:
config_fd = _open_runtime_file(path, 0o400)
manifest_fd_local = _open_runtime_file(_runtime_manifest_path(path), 0o600)
config_bytes, config_info = _read_runtime_fd(config_fd, "config")
manifest_bytes, manifest_info = _read_runtime_fd(manifest_fd_local, "manifest", 0o600)
if hashlib.sha256(manifest_bytes).hexdigest() != expected_manifest:
raise ConfigError("Manifest runtime modificato")
runtime_manifest = _strict_runtime_manifest(json.loads(manifest_bytes.decode("utf-8")))
if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest()
or int(runtime_manifest["config_dev"]) != config_info.st_dev
or int(runtime_manifest["config_ino"]) != config_info.st_ino
or int(runtime_manifest["config_size"]) != config_info.st_size
or int(runtime_manifest["config_uid"]) != config_info.st_uid
or int(runtime_manifest["config_nlink"]) != config_info.st_nlink
or config_info.st_dev == manifest_info.st_dev and config_info.st_ino == manifest_info.st_ino):
raise ConfigError("Identità config runtime non valida")
source_text = config_bytes.decode("utf-8")
except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
if isinstance(exc, ConfigError):
raise
raise ConfigError("File di configurazione runtime non attendibile") from exc
finally:
if config_fd is not None:
os.close(config_fd)
if manifest_fd_local is not None:
os.close(manifest_fd_local)
elif runtime_fd is not None or manifest_fd is not None or legacy_expected is not None:
# Compatibility for direct /dev/fd callers. New backend leases never use it.
if runtime_fd is None or manifest_fd is None or legacy_expected is None or not re.fullmatch(r"[0-9a-f]{64}", legacy_expected):
raise ConfigError("Handoff runtime incompleto")
try:
config_bytes, config_info = _read_runtime_fd(int(runtime_fd), "config")
manifest_bytes, manifest_info = _read_runtime_fd(int(manifest_fd), "manifest", 0o600)
if hashlib.sha256(manifest_bytes).hexdigest() != expected_manifest:
if hashlib.sha256(manifest_bytes).hexdigest() != legacy_expected:
raise ConfigError("Manifest runtime modificato")
runtime_manifest = _strict_runtime_manifest(json.loads(manifest_bytes.decode("utf-8")))
if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest()
@@ -655,6 +735,7 @@ def load_config(path: Path) -> Config:
source_text = path.read_text()
except OSError as exc:
raise ConfigError(f"File di configurazione non trovato: {path}") from exc
try:
raw = yaml.safe_load(source_text)
except yaml.YAMLError as exc:
+100 -56
View File
@@ -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",
}