fix: harden runtime config lease identity publication
This commit is contained in:
@@ -2,10 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -17,6 +19,99 @@ def fail(msg: str) -> None:
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def _canonical_root(root: str) -> str:
|
||||
# Darwin exposes /tmp and /var as symlink aliases. The trusted path boundary
|
||||
# records the real OS-owned prefix so a manifest never contains a symlink.
|
||||
if root == "/tmp" or root.startswith("/tmp/"):
|
||||
return "/private" + root
|
||||
if root == "/var" or root.startswith("/var/"):
|
||||
return "/private" + root
|
||||
return root
|
||||
|
||||
|
||||
def _identity(st: os.stat_result, path: str) -> dict[str, str]:
|
||||
return {"path": path, "dev": str(st.st_dev), "ino": str(st.st_ino),
|
||||
"mode": format(stat.S_IMODE(st.st_mode), "o"), "uid": str(st.st_uid)}
|
||||
|
||||
|
||||
def _rename_noreplace(src: str, dst: str, directory_fd: int, kind: str) -> None:
|
||||
"""Atomically rename *src* to *dst* without replacing an existing entry.
|
||||
|
||||
``link`` is deliberately not used here: the state-file protocol requires a
|
||||
rename, and a hard-link publication leaves a second name visible during a
|
||||
crash. Unsupported platforms fail closed rather than silently weakening the
|
||||
protocol. The env seam is intentionally narrow so fault tests can exercise
|
||||
every publication rename without monkey-patching the privileged process.
|
||||
"""
|
||||
if os.environ.get("THT_RUNTIME_CONFIG_RENAME_FAIL") in {"1", kind}:
|
||||
fail("runtime config rename failed")
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
src_b = os.fsencode(src)
|
||||
dst_b = os.fsencode(dst)
|
||||
if sys.platform == "darwin":
|
||||
fn = getattr(libc, "renameatx_np", None)
|
||||
if fn is None:
|
||||
fail("runtime config no-replace rename is unavailable")
|
||||
fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
|
||||
fn.restype = ctypes.c_int
|
||||
# RENAME_EXCL is the Darwin no-overwrite operation.
|
||||
rc = fn(directory_fd, src_b, directory_fd, dst_b, 0x00000004)
|
||||
elif sys.platform.startswith("linux"):
|
||||
# renameat2(2), RENAME_NOREPLACE. syscall numbers are stable for the
|
||||
# supported Linux architectures; an unavailable syscall fails closed.
|
||||
number = {"x86_64": 316, "aarch64": 276, "arm64": 276}.get(platform.machine())
|
||||
if number is None or not hasattr(libc, "syscall"):
|
||||
fail("runtime config no-replace rename is unavailable")
|
||||
libc.syscall.argtypes = [ctypes.c_long, ctypes.c_int, ctypes.c_char_p,
|
||||
ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
|
||||
libc.syscall.restype = ctypes.c_long
|
||||
rc = libc.syscall(number, directory_fd, src_b, directory_fd, dst_b, 1)
|
||||
else:
|
||||
fail("runtime config no-replace rename is unavailable")
|
||||
if rc != 0:
|
||||
err = ctypes.get_errno()
|
||||
if err == 17:
|
||||
raise FileExistsError(err, os.strerror(err), dst)
|
||||
raise OSError(err, os.strerror(err), dst)
|
||||
|
||||
|
||||
def directory_identities(root: str, paths: list[tuple[str, int]]) -> list[dict[str, str]]:
|
||||
"""Return the ordered, no-follow identity chain bound by a publication.
|
||||
|
||||
``paths`` contains canonical absolute directory paths paired with already-open
|
||||
descriptors. Prefix components are stat'ed without following symlinks; the
|
||||
terminal workspace-owned components are additionally represented by fstat on
|
||||
the descriptors opened by ``walk``.
|
||||
"""
|
||||
canonical = _canonical_root(root)
|
||||
root_parts = [part for part in Path(canonical).parts if part not in ("", "/")]
|
||||
entries: list[dict[str, str]] = []
|
||||
current = "/"
|
||||
st = os.stat("/", follow_symlinks=False)
|
||||
entries.append(_identity(st, current))
|
||||
for part in root_parts:
|
||||
current = (current.rstrip("/") + "/" + part) if current != "/" else "/" + part
|
||||
st = os.stat(current, follow_symlinks=False)
|
||||
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
|
||||
fail("runtime config directory is not trusted")
|
||||
entries.append(_identity(st, current))
|
||||
for path, fd in paths:
|
||||
cpath = _canonical_root(path)
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISDIR(st.st_mode) or stat.S_IMODE(st.st_mode) != 0o700 or st.st_uid != os.getuid():
|
||||
fail("runtime config directory is not trusted")
|
||||
# Keep one ordered entry per path. Existing prefixes are left in place.
|
||||
if not any(item["path"] == cpath for item in entries):
|
||||
entries.append(_identity(st, cpath))
|
||||
else:
|
||||
for item in entries:
|
||||
if item["path"] == cpath:
|
||||
if item["dev"] != str(st.st_dev) or item["ino"] != str(st.st_ino):
|
||||
fail("runtime config directory changed")
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
def safe_id(v: str) -> bool:
|
||||
return bool(__import__("re").fullmatch(r"[a-z][a-z0-9-]{2,62}", v))
|
||||
|
||||
@@ -162,7 +257,7 @@ def strict_manifest(value: object) -> dict:
|
||||
"version", "workspace_id", "workspace_revision", "descriptor_git_blob",
|
||||
"descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_sha256",
|
||||
"config_dwh_binding", "config_dev", "config_ino", "config_size", "config_mode",
|
||||
"config_uid", "config_nlink",
|
||||
"config_uid", "config_nlink", "directory_identities",
|
||||
}
|
||||
if set(value) != required or value.get("version") != 1:
|
||||
fail("runtime config manifest is invalid")
|
||||
@@ -179,6 +274,17 @@ def strict_manifest(value: object) -> dict:
|
||||
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")
|
||||
identities = value.get("directory_identities")
|
||||
if (not isinstance(identities, list) or not identities or
|
||||
any(not isinstance(item, dict) or set(item) != {"path", "dev", "ino", "mode", "uid"}
|
||||
or not isinstance(item["path"], str) or not os.path.isabs(item["path"])
|
||||
or any(not isinstance(item[key], str) or not item[key].isdigit()
|
||||
for key in ("dev", "ino", "mode", "uid"))
|
||||
or item["mode"] == "0"
|
||||
for item in identities)):
|
||||
fail("runtime config manifest is invalid")
|
||||
if len({item["path"] for item in identities}) != len(identities):
|
||||
fail("runtime config manifest is invalid")
|
||||
if value["config_mode"] != "400":
|
||||
fail("runtime config manifest is invalid")
|
||||
return value
|
||||
@@ -213,6 +319,14 @@ def publish(inp: dict) -> dict:
|
||||
checked_dir(cfgdir)
|
||||
mandir = open_dir(prep, "runtime-config-manifests", True)
|
||||
checked_dir(mandir)
|
||||
canonical = _canonical_root(root)
|
||||
directory_manifest = directory_identities(root, [
|
||||
(f"{canonical}/sessions", sessions),
|
||||
(f"{canonical}/sessions/{wid}", ws),
|
||||
(f"{canonical}/sessions/{wid}/preprocessing", prep),
|
||||
(f"{canonical}/sessions/{wid}/preprocessing/runtime-config", cfgdir),
|
||||
(f"{canonical}/sessions/{wid}/preprocessing/runtime-config-manifests", mandir),
|
||||
])
|
||||
# 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)
|
||||
@@ -249,15 +363,11 @@ def publish(inp: dict) -> dict:
|
||||
os.fchmod(fd, 0o400)
|
||||
os.fsync(fd)
|
||||
try:
|
||||
os.link(
|
||||
stage, name, src_dir_fd=cfgdir, dst_dir_fd=cfgdir, follow_symlinks=False
|
||||
)
|
||||
_rename_noreplace(stage, name, cfgdir, "config")
|
||||
except FileExistsError:
|
||||
# A concurrent equal publisher may already have won. It is
|
||||
# accepted only after reopening and comparing its bytes below.
|
||||
pass
|
||||
# Keep metadata ordering explicit even on filesystems where a
|
||||
# hardlink publication does not retain fchmod as expected.
|
||||
os.fchmod(fd, 0o400)
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
@@ -290,6 +400,7 @@ def publish(inp: dict) -> dict:
|
||||
"config_mode": format(stat.S_IMODE(s.st_mode), "o"),
|
||||
"config_uid": str(s.st_uid),
|
||||
"config_nlink": str(s.st_nlink),
|
||||
"directory_identities": directory_manifest,
|
||||
}
|
||||
)
|
||||
strict_manifest(manifest)
|
||||
@@ -314,7 +425,7 @@ def publish(inp: dict) -> dict:
|
||||
os.fchmod(fd, 0o600)
|
||||
os.fsync(fd)
|
||||
try:
|
||||
os.link(stage, mname, src_dir_fd=mandir, dst_dir_fd=mandir, follow_symlinks=False)
|
||||
_rename_noreplace(stage, mname, mandir, "manifest")
|
||||
except FileExistsError:
|
||||
# A no-replace loser is successful only after validating the
|
||||
# durable winner byte-for-byte and against the strict schema.
|
||||
@@ -438,14 +549,22 @@ def verified_snapshot(inp: dict) -> dict:
|
||||
repo = inp.get("repository_root")
|
||||
if not isinstance(repo, str) or not os.path.isabs(repo):
|
||||
fail("invalid repository root")
|
||||
# Git replacement refs and ambient repository/config variables are attacker
|
||||
# controlled process state. Snapshot identity must be the raw object named by
|
||||
# the commit, with fixed Git configuration and repository boundaries.
|
||||
# Keep no inherited GIT_* controls at all (including GIT_CONFIG_PARAMETERS,
|
||||
# alternates, and repository path overrides), then add only fixed semantics.
|
||||
git_env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
|
||||
git_env.update({"GIT_NO_REPLACE_OBJECTS": "1", "GIT_CONFIG_NOSYSTEM": "1",
|
||||
"GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull})
|
||||
try:
|
||||
blob = subprocess.check_output(
|
||||
["git", "-C", repo, "rev-parse", f"{rev}:workspaces/{wid}.yaml"],
|
||||
stderr=subprocess.DEVNULL, text=True, timeout=5,
|
||||
["git", "--no-replace-objects", "-C", repo, "rev-parse", f"{rev}:workspaces/{wid}.yaml"],
|
||||
stderr=subprocess.DEVNULL, text=True, timeout=5, env=git_env,
|
||||
).strip()
|
||||
git_source = subprocess.check_output(
|
||||
["git", "-C", repo, "show", f"{rev}:workspaces/{wid}.yaml"],
|
||||
stderr=subprocess.DEVNULL, timeout=5,
|
||||
["git", "--no-replace-objects", "-C", repo, "show", f"{rev}:workspaces/{wid}.yaml"],
|
||||
stderr=subprocess.DEVNULL, timeout=5, env=git_env,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
fail("workspace Git revision is unavailable")
|
||||
|
||||
Reference in New Issue
Block a user