From 7a7d2de96b22a9adf9208afcc9547e1001569fef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Thu, 22 Jan 2026 01:41:14 +0200 Subject: [PATCH] Fixed security issue around wheel unpack (#675) A maliciously crafted wheel could cause the permissions of a file outside the unpack tree to be altered. Fixes CVE-2026-24049. --- docs/news.rst | 2 ++ src/wheel/_commands/unpack.py | 4 ++-- tests/commands/test_unpack.py | 23 +++++++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) Index: wheel-0.42.0/src/wheel/cli/unpack.py =================================================================== --- wheel-0.42.0.orig/src/wheel/cli/unpack.py +++ wheel-0.42.0/src/wheel/cli/unpack.py @@ -19,12 +19,12 @@ def unpack(path: str, dest: str = ".") - destination = Path(dest) / namever print(f"Unpacking to: {destination}...", end="", flush=True) for zinfo in wf.filelist: - wf.extract(zinfo, destination) + target_path = Path(wf.extract(zinfo, destination)) # Set permissions to the same values as they were set in the archive # We have to do this manually due to # https://github.com/python/cpython/issues/59999 permissions = zinfo.external_attr >> 16 & 0o777 - destination.joinpath(zinfo.filename).chmod(permissions) + target_path.chmod(permissions) print("OK") Index: wheel-0.42.0/tests/cli/test_unpack.py =================================================================== --- wheel-0.42.0.orig/tests/cli/test_unpack.py +++ wheel-0.42.0/tests/cli/test_unpack.py @@ -8,6 +8,7 @@ import pytest from wheel.cli.unpack import unpack from wheel.wheelfile import WheelFile +from .util import run_command def test_unpack(wheel_paths, tmp_path): """ @@ -34,3 +35,26 @@ def test_unpack_executable_bit(tmp_path) unpack(str(wheel_path), str(tmp_path)) assert not script_path.is_dir() assert stat.S_IMODE(script_path.stat().st_mode) == 0o755 + + +@pytest.mark.skipif( + platform.system() == "Windows", reason="Windows does not support chmod()" +) +def test_chmod_outside_unpack_tree(tmp_path_factory: TempPathFactory) -> None: + wheel_path = tmp_path_factory.mktemp("build") / "test-1.0-py3-none-any.whl" + with WheelFile(wheel_path, "w") as wf: + wf.writestr( + "test-1.0.dist-info/METADATA", + "Metadata-Version: 2.4\nName: test\nVersion: 1.0\n", + ) + wf.writestr("../../system-file", b"malicious data") + + extract_root_path = tmp_path_factory.mktemp("extract") + system_file = extract_root_path / "system-file" + extract_path = extract_root_path / "subdir" + system_file.write_bytes(b"important data") + system_file.chmod(0o755) + run_command("unpack", "--dest", extract_path, wheel_path) + + assert system_file.read_bytes() == b"important data" + assert stat.S_IMODE(system_file.stat().st_mode) == 0o755 Index: wheel-0.42.0/tests/cli/util.py =================================================================== --- /dev/null +++ wheel-0.42.0/tests/cli/util.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import sys +from io import StringIO +from os import PathLike +from subprocess import CalledProcessError +from unittest.mock import patch + +import pytest + +from wheel.cli import main + + +def run_command( + command: str, *args: str | PathLike, catch_systemexit: bool = True +) -> str: + returncode = 0 + stdout = StringIO() + stderr = StringIO() + args = ("wheel", command) + tuple(str(arg) for arg in args) + with ( + patch.object(sys, "argv", args), + patch.object(sys, "stdout", stdout), + patch.object(sys, "stderr", stderr), + ): + try: + main() + except SystemExit as exc: + if not catch_systemexit: + raise CalledProcessError( + exc.code, args, stdout.getvalue(), stderr.getvalue() + ) from exc + + returncode = exc.code + + if returncode: + pytest.fail( + f"'wheel {command}' exited with return code {returncode}\n" + f"arguments: {args}\n" + f"error output:\n{stderr.getvalue()}" + ) + + return stdout.getvalue() Index: wheel-0.42.0/tests/cli/__init__.py =================================================================== --- /dev/null +++ wheel-0.42.0/tests/cli/__init__.py @@ -0,0 +1 @@ +