From 50051fba8c38f7de2be78217a1c6ed90535cbed7 Mon Sep 17 00:00:00 2001 From: Sunil Date: Sun, 26 Jul 2026 00:10:49 +0530 Subject: [PATCH] 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. --- strix/interface/update_check.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/strix/interface/update_check.py b/strix/interface/update_check.py index 56ed4cd53..fdfc4a65b 100644 --- a/strix/interface/update_check.py +++ b/strix/interface/update_check.py @@ -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)