sec(updater): harden tarball extraction and verify sha256 before extract

Reject symlinks, hardlinks, device nodes and FIFOs in update tarballs;
validate each member's resolved path stays under dest after symlink
resolution; cap uncompressed size at 256 MiB to bound gzip-bomb damage;
strip setuid/setgid bits from extracted modes.

Add an optional sha256 form field to /update and /update-self; the
master client computes and sends it on every push, the executor
refuses to extract on mismatch. mTLS already authenticates the
master, so this is defence-in-depth against in-transit corruption
and gives operators a way to pin "exactly these bytes" for vetted
releases.
This commit is contained in:
2026-04-27 21:14:48 -04:00
parent 1de4136ed9
commit 28e2a93355
5 changed files with 203 additions and 8 deletions

View File

@@ -51,7 +51,7 @@ def test_health_returns_role_and_releases(client: TestClient, monkeypatch: pytes
def test_update_happy_path(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
ex, "run_update",
lambda data, sha, install_dir, agent_dir: {"status": "updated", "release": {"slot": "active", "sha": sha}, "probe": "ok"},
lambda data, sha, install_dir, agent_dir, expected_sha256=None: {"status": "updated", "release": {"slot": "active", "sha": sha}, "probe": "ok"},
)
r = client.post(
"/update",
@@ -97,7 +97,7 @@ def test_update_self_requires_confirm(client: TestClient) -> None:
def test_update_self_happy_path(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
ex, "run_update_self",
lambda data, sha, updater_install_dir: {"status": "self_update_queued", "argv": ["python", "-m", "decnet", "updater"]},
lambda data, sha, updater_install_dir, expected_sha256=None: {"status": "self_update_queued", "argv": ["python", "-m", "decnet", "updater"]},
)
r = client.post(
"/update-self",

View File

@@ -96,6 +96,116 @@ def test_extract_happy_path(tmp_path: pathlib.Path) -> None:
assert (out / "a" / "b.txt").read_text() == "hello"
def _tarball_with_link(linkname: str, target: str, *, hard: bool = False) -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
info = tarfile.TarInfo(name=linkname)
info.type = tarfile.LNKTYPE if hard else tarfile.SYMTYPE
info.linkname = target
tar.addfile(info)
return buf.getvalue()
def test_extract_rejects_symlinks(tmp_path: pathlib.Path) -> None:
evil = _tarball_with_link("link.txt", "/etc/passwd")
with pytest.raises(ex.UpdateError, match="only regular files"):
ex.extract_tarball(evil, tmp_path / "out")
def test_extract_rejects_hardlinks(tmp_path: pathlib.Path) -> None:
evil = _tarball_with_link("link.txt", "real.txt", hard=True)
with pytest.raises(ex.UpdateError, match="only regular files"):
ex.extract_tarball(evil, tmp_path / "out")
def test_extract_rejects_device_nodes(tmp_path: pathlib.Path) -> None:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
info = tarfile.TarInfo(name="dev_null")
info.type = tarfile.CHRTYPE
info.devmajor = 1
info.devminor = 3
tar.addfile(info)
with pytest.raises(ex.UpdateError, match="only regular files"):
ex.extract_tarball(buf.getvalue(), tmp_path / "out")
def test_extract_rejects_oversized_tarball(
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
# Lower the cap rather than building a 256 MiB tarball in memory.
monkeypatch.setattr(ex, "MAX_TARBALL_UNCOMPRESSED_BYTES", 32)
big = _make_tarball({"big.txt": "x" * 64})
with pytest.raises(ex.UpdateError, match="exceeds size cap"):
ex.extract_tarball(big, tmp_path / "out")
def test_extract_strips_setuid_bit(tmp_path: pathlib.Path) -> None:
buf = io.BytesIO()
payload = b"hello"
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
info = tarfile.TarInfo(name="suid.bin")
info.size = len(payload)
info.mode = 0o4755 # setuid + rwxr-xr-x
tar.addfile(info, io.BytesIO(payload))
out = tmp_path / "out"
ex.extract_tarball(buf.getvalue(), out)
mode = (out / "suid.bin").stat().st_mode & 0o7777
assert mode & 0o4000 == 0, f"setuid bit should be stripped, got {oct(mode)}"
# ----------------------------------------------------------- sha256 verify
def test_run_update_rejects_sha256_mismatch(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
agent_dir: pathlib.Path,
) -> None:
monkeypatch.setattr(ex, "_run_pip", lambda release: _PipOK())
monkeypatch.setattr(ex, "_stop_agent", lambda *a, **k: None)
monkeypatch.setattr(ex, "_spawn_agent", lambda *a, **k: 1)
monkeypatch.setattr(ex, "_probe_agent", lambda **_: (True, "ok"))
tb = _make_tarball({"marker.txt": "new"})
bad = "0" * 64
with pytest.raises(ex.UpdateError, match="sha256 mismatch"):
ex.run_update(
tb, sha="S", install_dir=install_dir, agent_dir=agent_dir,
expected_sha256=bad,
)
# Mismatch must abort before staging is left around.
assert not (install_dir / "releases" / "active.new").exists()
def test_run_update_accepts_correct_sha256(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
agent_dir: pathlib.Path,
) -> None:
import hashlib as _hl
monkeypatch.setattr(ex, "_run_pip", lambda release: _PipOK())
monkeypatch.setattr(ex, "_stop_agent", lambda *a, **k: None)
monkeypatch.setattr(ex, "_spawn_agent", lambda *a, **k: 1)
monkeypatch.setattr(ex, "_probe_agent", lambda **_: (True, "ok"))
tb = _make_tarball({"marker.txt": "new"})
digest = _hl.sha256(tb).hexdigest()
result = ex.run_update(
tb, sha="S", install_dir=install_dir, agent_dir=agent_dir,
expected_sha256=digest,
)
assert result["status"] == "updated"
def test_run_update_rejects_malformed_sha256(
install_dir: pathlib.Path, agent_dir: pathlib.Path,
) -> None:
tb = _make_tarball({"x.txt": "y"})
with pytest.raises(ex.UpdateError, match="not a 64-char hex"):
ex.run_update(
tb, sha="S", install_dir=install_dir, agent_dir=agent_dir,
expected_sha256="not-a-hex-digest",
)
def test_clean_stale_staging(install_dir: pathlib.Path) -> None:
staging = install_dir / "releases" / "active.new"
staging.mkdir()