fix: harden runtime config snapshot publication
This commit is contained in:
+92
-11
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user