Security review: fix traversal, identity, injection and takeover paths
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 34s

Findings from a full review (auth/API, CA and rack channel, config
generation, integrations, UI/agents/scripts), each verified in code:

- rack binary endpoint read any region file via arch=../.. (CA key)
- render endpoint traversal; machine-reported MACs used as file paths
- delegated intermediates could mint admin/rack identities and IPs;
  identities now require root-issued certs, intermediates are
  ServerAuth-only with both name types constrained
- rack certs were ServerAuth for rack-chosen SANs: now client-only;
  racks accept only the region's own cert; join checks the CA
  fingerprint; unbound join codes cannot take an existing name; job
  results bound to the rack that took them
- rack self-update: region signs binaries with the CA key, the root
  unit verifies against the join-time CA in a root-owned dir
- dnsmasq reads a checked, root-owned copy limited to generated options
- commissioning could overwrite deployed machines; MAC takeover of PXE
- injection into dnsmasq/cloud-init/PXE/udev/DNS via names and
  machine-reported strings; stored XSS in the UI; CSP and cross-origin
  protection; deploy agent shell injection and seed traversal
- Redfish requests confined to the BMC's /redfish/ tree
- admin_http defaults to loopback; body limits and timeouts; secrets
  off command lines

Also: IPAM claim/reconcile race, sync stopping on one error, watchdog
race, release wiping the wrong disk, SAN LUNs as auto targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-29 17:58:42 +01:00
1 parent 6e4be736ec
commit a549f01ef5
28 files changed
+782 -154

No files matched your search

+47 -26
View File
@@ -12,6 +12,7 @@ Everything the agent needs (image URL, checksum, disk, seed files) comes from
GET /api/v1/jobs/self; it never decides policy itself.
"""
import glob
import hashlib
import json
import os
import re
@@ -156,7 +157,9 @@ def find_disk(spec):
log(f"{want} not found; serial {serial} is {d['path']}")
return d
raise RuntimeError(f"target disk {want} not present")
cands = [d for d in disks if not d.get("rm") and d.get("tran") != "usb" and int(d["size"]) >= need]
# never a USB stick or a SAN LUN by itself (the region picks the same way)
remote = {"usb", "iscsi", "fc", "fcoe", "srp", "iser"}
cands = [d for d in disks if not d.get("rm") and d.get("tran") not in remote and int(d["size"]) >= need]
if not cands:
raise RuntimeError(f"no disk >= {need} bytes")
cands.sort(key=lambda d: (int(d["size"]), d["name"]))
@@ -182,31 +185,47 @@ def release_disk(path):
# --------------------------------------------------------------------------- deploy
def write_image(spec, disk, prog):
url, want, size = spec["image_url"], spec["sha256"], int(spec.get("size") or 0)
sumf, pvf = "/run/rollcall.sum", "/run/rollcall.pv"
for f in (sumf, pvf):
if os.path.exists(f):
os.remove(f)
pipeline = (f"set -o pipefail; curl -fsSL --retry 5 --retry-connrefused '{url}' "
f"| tee >(sha256sum > {sumf}) "
f"| pv -n -f -i 2 -s {size} 2>{pvf} "
f"| zstd -dc -T0 "
f"| dd of='{disk}' bs=4M iflag=fullblock oflag=direct conv=fsync status=none")
if not re.match(r"^https?://", url):
raise RuntimeError(f"image url {url!r} is not http(s)")
pvf = "/run/rollcall.pv"
if os.path.exists(pvf):
os.remove(pvf)
# values go in as arguments, never into the shell text; the checksum is taken
# here, over exactly the bytes handed to the writer
down = subprocess.Popen(
["bash", "-c", 'set -o pipefail; pv -n -f -i 2 -s "$1" 2>"$2" | zstd -dc -T0 '
'| dd of="$3" bs=4M iflag=fullblock oflag=direct conv=fsync status=none', "write", str(size), pvf, disk],
stdin=subprocess.PIPE, stderr=subprocess.PIPE)
curl = subprocess.Popen(["curl", "-fsSL", "--retry", "5", "--retry-connrefused", "--", url],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
log(f"writing {url} -> {disk}")
proc = subprocess.Popen(["bash", "-c", pipeline], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
pct = 0
while proc.poll() is None:
time.sleep(2)
try:
with open(pvf) as f:
lines = f.read().split()
if lines:
pct = int(lines[-1])
except (OSError, ValueError):
pass
prog.set("writing", min(pct, 99), f"{pct}% of {size // (1 << 20)} MiB written to {disk}")
if proc.returncode != 0:
raise RuntimeError(f"image write failed rc={proc.returncode}: {proc.stderr.read().strip()[-400:]}")
got = readf(sumf).split()[0] if readf(sumf) else ""
h, pct, shown = hashlib.sha256(), 0, 0.0
try:
while True:
b = curl.stdout.read(1 << 20)
if not b:
break
h.update(b)
down.stdin.write(b)
if time.time() - shown >= 2:
shown = time.time()
try:
with open(pvf) as f:
lines = f.read().split()
if lines:
pct = int(lines[-1])
except (OSError, ValueError):
pass
prog.set("writing", min(pct, 99), f"{pct}% of {size // (1 << 20)} MiB written to {disk}")
down.stdin.close()
except BrokenPipeError:
pass
crc, drc = curl.wait(), down.wait()
if crc != 0:
raise RuntimeError(f"download failed rc={crc}: {curl.stderr.read().decode(errors='replace').strip()[-400:]}")
if drc != 0:
raise RuntimeError(f"image write failed rc={drc}: {down.stderr.read().decode(errors='replace').strip()[-400:]}")
got = h.hexdigest()
if got != want:
raise RuntimeError(f"sha256 mismatch: got {got} want {want}")
run(["sync"])
@@ -244,7 +263,9 @@ def seed(root, files):
if not os.path.isdir(f"{mnt}/etc"):
raise RuntimeError(f"{root['path']} has no /etc; not a root filesystem")
for f in files:
dst = os.path.join(mnt, f["path"].lstrip("/"))
dst = os.path.realpath(os.path.join(mnt, f["path"].lstrip("/")))
if not dst.startswith(mnt + "/"):
raise RuntimeError(f"seed file {f['path']!r} is outside the root filesystem")
os.makedirs(os.path.dirname(dst), exist_ok=True)
with open(dst, "w") as fh:
fh.write(f["content"])