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

@@ -12,6 +12,7 @@ the connection on purpose (the updater re-execs itself mid-response).
"""
from __future__ import annotations
import hashlib
import ssl
from typing import Any, Optional
@@ -36,7 +37,12 @@ class UpdaterClient:
address: Optional[str] = None,
updater_port: int = 8766,
identity: Optional[MasterIdentity] = None,
verify_hostname: Optional[bool] = None,
):
if verify_hostname is None:
from decnet.env import DECNET_VERIFY_HOSTNAME
verify_hostname = DECNET_VERIFY_HOSTNAME
self._verify_hostname = verify_hostname
if host is not None:
self._address = host["address"]
self._host_name = host.get("name")
@@ -56,7 +62,7 @@ class UpdaterClient:
)
ctx.load_verify_locations(cafile=str(self._identity.ca_cert_path))
ctx.verify_mode = ssl.CERT_REQUIRED
ctx.check_hostname = False
ctx.check_hostname = self._verify_hostname
return httpx.AsyncClient(
base_url=f"https://{self._address}:{self._port}",
verify=ctx,
@@ -93,12 +99,13 @@ class UpdaterClient:
"""POST /update. Returns the Response so the caller can distinguish
200 / 409 / 500 — each means something different.
"""
sha256 = hashlib.sha256(tarball).hexdigest()
self._require().timeout = _TIMEOUT_UPDATE
try:
r = await self._require().post(
"/update",
files={"tarball": ("tree.tgz", tarball, "application/gzip")},
data={"sha": sha},
data={"sha": sha, "sha256": sha256},
)
finally:
self._require().timeout = _TIMEOUT_CONTROL
@@ -109,12 +116,13 @@ class UpdaterClient:
usually drops mid-response; that's not an error. Callers should then
poll /health until the new SHA appears.
"""
sha256 = hashlib.sha256(tarball).hexdigest()
self._require().timeout = _TIMEOUT_UPDATE
try:
r = await self._require().post(
"/update-self",
files={"tarball": ("tree.tgz", tarball, "application/gzip")},
data={"sha": sha, "confirm_self": "true"},
data={"sha": sha, "sha256": sha256, "confirm_self": "true"},
)
finally:
self._require().timeout = _TIMEOUT_CONTROL