security: harden self-update against supply-chain and path-traversal attacks

Three related hardening measures for _download_and_replace():

1. REFUSE UNVERIFIED BINARIES (was: silent skip)

   When no SHA-256 digest is published for a release asset, the
   previous code printed a dim warning and proceeded to replace the
   running binary with unverified content.  If an attacker compromises
   the GitHub release (account takeover, Actions supply-chain, CDN
   MitM on a misconfigured proxy), they can inject a malicious binary
   that is installed without any integrity check.

   Now: raise RuntimeError so the update is aborted.  The operator can
   still install manually after independent verification.

2. ZIP-SLIP GUARD (Windows path)

   zipfile.extract() uses the archive member name as-is.  A crafted
   zip containing '../../malicious.exe' writes outside the staging
   directory.  Python >=3.12 has partial mitigations, but Python 3.11
   (in-support per pyproject.toml requires-python) is fully vulnerable.

   Now: resolve the extraction target and verify it stays inside
   tmp_dir before extracting.

3. TAR-SLIP GUARD + PYTHON 3.11 FALLBACK (Unix path)

   tarfile.extract(filter='data') was introduced in Python 3.12.
   On Python 3.11 it raises TypeError, causing the self-update to
   crash.  Additionally, without the filter on 3.11, tar members
   with absolute paths or '..' components could escape.

   Now: validate the resolved path (like the zip guard), use
   filter='data' when available, and fall back gracefully on 3.11.
This commit is contained in:
Sunil 2026-07-26 00:10:49 +05:30
parent 08126eb518
commit 50051fba8c

View file

@ -306,14 +306,38 @@ def _download_and_replace(version: str, target: str, console: Console) -> bool:
f"expected sha256 {expected_digest}, got {actual_digest}"
)
else:
console.print("[dim yellow]No published checksum available; skipping verification[/]")
raise RuntimeError(
f"No published checksum available for {filename}; "
f"refusing to install an unverified binary. "
f"Please ensure the release includes asset digests."
)
if is_windows:
with zipfile.ZipFile(archive_path) as zf:
zf.extract(binary_name, tmp_dir)
# Zip-slip guard: verify the member does not escape tmp_dir.
member = zf.getinfo(binary_name)
extracted = (tmp_dir / member.filename).resolve()
if not str(extracted).startswith(str(tmp_dir.resolve())):
raise RuntimeError(
f"Zip member {member.filename!r} would escape "
f"the staging directory (zip-slip)"
)
zf.extract(member, tmp_dir)
else:
with tarfile.open(archive_path, "r:gz") as tf:
tf.extract(binary_name, tmp_dir, filter="data")
# Tar-slip guard: verify the member does not escape tmp_dir.
member = tf.getmember(binary_name)
extracted = (tmp_dir / member.name).resolve()
if not str(extracted).startswith(str(tmp_dir.resolve())):
raise RuntimeError(
f"Tar member {member.name!r} would escape "
f"the staging directory (tar-slip)"
)
try:
tf.extract(member, tmp_dir, filter="data")
except TypeError:
# Python <3.12 does not support the filter parameter.
tf.extract(member, tmp_dir)
new_binary = tmp_dir / binary_name
new_binary.chmod(new_binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)