diff --git a/SPECS/poetry/CVE-2026-41140.patch b/SPECS/poetry/CVE-2026-41140.patch new file mode 100644 index 00000000000..f11ebeaf347 --- /dev/null +++ b/SPECS/poetry/CVE-2026-41140.patch @@ -0,0 +1,387 @@ +From a63b68b2f8e952a7d72c39a06817b5fbddd22c4c Mon Sep 17 00:00:00 2001 +From: AllSpark +Date: Wed, 29 Apr 2026 18:44:11 +0000 +Subject: [PATCH] fix(utils.extractall): refuse to write files outside target + directory during sdist and wheel extraction; centralize traversal fixtures + and add tests for tar and zip extraction safety; update broken tarfile filter + versions + +Signed-off-by: Azure Linux Security Servicing Account +Upstream-reference: AI Backport of https://github.com/python-poetry/poetry/commit/47e97340cae50d3698aac858732788861ba8dd1f.patch +--- + src/poetry/utils/helpers.py | 37 ++++- + tests/conftest.py | 82 ++++++++++ + tests/installation/test_wheel_installer.py | 26 ---- + tests/utils/test_helpers.py | 167 +++++++++++++++++++++ + 4 files changed, 284 insertions(+), 28 deletions(-) + +diff --git a/src/poetry/utils/helpers.py b/src/poetry/utils/helpers.py +index fbc29e5..6797c19 100644 +--- a/src/poetry/utils/helpers.py ++++ b/src/poetry/utils/helpers.py +@@ -362,7 +362,7 @@ def extractall(source: Path, dest: Path, zip: bool) -> None: + else: + # These versions of python shipped with a broken tarfile data_filter, per + # https://github.com/python/cpython/issues/107845. +- broken_tarfile_filter = {(3, 8, 17), (3, 9, 17), (3, 10, 12), (3, 11, 4)} ++ broken_tarfile_filter = {(3, 10, 12), (3, 11, 4)} + with tarfile.open(source) as archive: + if ( + hasattr(tarfile, "data_filter") +@@ -370,4 +370,37 @@ def extractall(source: Path, dest: Path, zip: bool) -> None: + ): + archive.extractall(dest, filter="data") + else: +- archive.extractall(dest) ++ # Validate all member paths before extraction ++ # ++ # Attention: Path.absolute() is not sufficient because it does not ++ # normalize, i.e. does not remove "..". ++ # ++ # We want to avoid Path.resolve() because it is significantly slower ++ # than os.path.abspath()! ++ dest = Path(os.path.abspath(dest)) ++ safe_members = [] ++ for member in archive.getmembers(): ++ member_path = Path(os.path.abspath(dest / member.name)) ++ if not member_path.is_relative_to(dest): ++ raise ValueError( ++ f"Refusing to extract {member.name}: " ++ f"would write outside {dest}" ++ ) ++ if member.issym(): ++ link_target = Path( ++ os.path.abspath(member_path.parent / member.linkname) ++ ) ++ if not link_target.is_relative_to(dest): ++ raise ValueError( ++ f"Refusing symlink {member.name}: " ++ f"target {member.linkname} outside {dest}" ++ ) ++ elif member.islnk(): ++ link_target = Path(os.path.abspath(dest / member.linkname)) ++ if not link_target.is_relative_to(dest): ++ raise ValueError( ++ f"Refusing hardlink {member.name}: " ++ f"target {member.linkname} outside {dest}" ++ ) ++ safe_members.append(member) ++ archive.extractall(dest, members=safe_members) +diff --git a/tests/conftest.py b/tests/conftest.py +index 5955c81..0c72188 100644 +--- a/tests/conftest.py ++++ b/tests/conftest.py +@@ -525,3 +525,85 @@ def venv_flags_default() -> dict[str, bool]: + def httpretty_windows_mock_urllib3_wait_for_socket(mocker: MockerFixture) -> None: + # this is a workaround for https://github.com/gabrielfalcao/HTTPretty/issues/442 + mocker.patch("urllib3.util.wait.select_wait_for_socket", returns=True) ++ ++ ++@pytest.fixture(params=[False, True]) # relative path ++def wheel_with_path_traversal(tmp_path: Path, request: pytest.FixtureRequest) -> Path: ++ import zipfile ++ ++ traversal_path = ( ++ "../../traversal.txt" ++ if request.param ++ else (tmp_path / "traversal.txt").as_posix() ++ ) ++ ++ wheel = tmp_path / "traversal-0.1-py3-none-any.whl" ++ files = { ++ "traversal/__init__.py": b"", ++ traversal_path: b"path traversal", ++ "traversal-0.1.dist-info/WHEEL": ( ++ b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n" ++ ), ++ "traversal-0.1.dist-info/METADATA": ( ++ b"Metadata-Version: 2.1\nName: traversal\nVersion: 0.1\n" ++ ), ++ } ++ files["traversal-0.1.dist-info/RECORD"] = ( ++ "\n".join([f"{k},," for k in files] + ["traversal-0.1.dist-info/RECORD,,"]) ++ + "\n" ++ ).encode() ++ ++ with zipfile.ZipFile(wheel, "w") as z: ++ for k, v in files.items(): ++ z.writestr(k, v) ++ ++ return wheel ++ ++ ++@pytest.fixture(params=[False, True]) # relative path ++def wheel_with_path_traversal_via_symlink( ++ tmp_path: Path, request: pytest.FixtureRequest ++) -> Path: ++ import stat ++ import zipfile ++ ++ wheel = tmp_path / "symlink-0.1-py3-none-any.whl" ++ files = { ++ "symlink/__init__.py": b"", ++ "symlink-0.1.dist-info/WHEEL": ( ++ b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n" ++ ), ++ "symlink-0.1.dist-info/METADATA": ( ++ b"Metadata-Version: 2.1\nName: symlink-pkg\nVersion: 0.1\n" ++ ), ++ } ++ ++ symlink_entry = "symlink/traversal_link" ++ symlink_target = ( ++ b"../../target" ++ if request.param ++ else (tmp_path / "target").as_posix().encode("utf-8") ++ ) ++ traversal_file = "symlink/traversal_link/traversal.txt" ++ ++ record_lines = [f"{k},," for k in files] ++ record_lines.append(f"{symlink_entry},,") ++ record_lines.append(f"{traversal_file},,") ++ record_lines.append("symlink-0.1.dist-info/RECORD,,") ++ files["symlink-0.1.dist-info/RECORD"] = ("\n".join(record_lines) + "\n").encode() ++ ++ with zipfile.ZipFile(wheel, "w") as z: ++ for k, v in files.items(): ++ z.writestr(k, v) ++ ++ # Add a ZIP entry whose external attributes mark it as a symlink. ++ # The entry's data is the symlink target, pointing outside the ++ # installation directory. ++ info = zipfile.ZipInfo(symlink_entry) ++ info.create_system = 3 # unix ++ info.external_attr = (stat.S_IFLNK | 0o777) << 16 ++ z.writestr(info, symlink_target) ++ ++ z.writestr(traversal_file, b"path traversal") ++ ++ return wheel +diff --git a/tests/installation/test_wheel_installer.py b/tests/installation/test_wheel_installer.py +index f891b3c..7ac9a82 100644 +--- a/tests/installation/test_wheel_installer.py ++++ b/tests/installation/test_wheel_installer.py +@@ -97,32 +97,6 @@ def test_install_dir_is_symlink(tmp_path: Path, demo_wheel: Path) -> None: + assert (Path(env.paths["purelib"]) / "demo").exists() + + +-@pytest.fixture +-def wheel_with_path_traversal(tmp_path: Path) -> Path: +- import zipfile +- +- wheel = tmp_path / "traversal-0.1-py3-none-any.whl" +- files = { +- "traversal/__init__.py": b"", +- "../../traversal.txt": b"", +- "traversal-0.1.dist-info/WHEEL": ( +- b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n" +- ), +- "traversal-0.1.dist-info/METADATA": ( +- b"Metadata-Version: 2.1\nName: traversal\nVersion: 0.1\n" +- ), +- } +- files["traversal-0.1.dist-info/RECORD"] = ( +- "\n".join([f"{k},," for k in files] + ["traversal-0.1.dist-info/RECORD,,"]) +- + "\n" +- ).encode() +- +- with zipfile.ZipFile(wheel, "w") as z: +- for k, v in files.items(): +- z.writestr(k, v) +- +- return wheel +- + + def test_path_traversal(env: MockEnv, wheel_with_path_traversal: Path) -> None: + installer = WheelInstaller(env) +diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py +index 2399e29..669588b 100644 +--- a/tests/utils/test_helpers.py ++++ b/tests/utils/test_helpers.py +@@ -3,12 +3,17 @@ from __future__ import annotations + from typing import TYPE_CHECKING + from typing import Any + ++import contextlib ++import sys ++import tarfile + import pytest + + from poetry.core.utils.helpers import parse_requires + ++from poetry.utils._compat import WINDOWS + from poetry.utils.helpers import HTTPRangeRequestSupported + from poetry.utils.helpers import download_file ++from poetry.utils.helpers import extractall + from poetry.utils.helpers import get_file_hash + from poetry.utils.helpers import get_highest_priority_hash_type + +@@ -188,3 +193,165 @@ def test_download_file_raise_accepts_ranges( + else: + download_file(url, dest, raise_accepts_ranges=raise_accepts_ranges) + assert dest.is_file() ++ ++ ++@pytest.mark.parametrize("relative", [False, True]) ++@pytest.mark.parametrize("existing", [False, True]) ++def test_extractall_sdist_no_path_traversal( ++ tmp_path: Path, relative: bool, existing: bool ++) -> None: ++ import io ++ import tarfile ++ ++ archive = tmp_path / "traversal.tar.gz" ++ dest = tmp_path / "dest" ++ dest.mkdir() ++ ++ target = tmp_path / "traversal.txt" ++ if existing: ++ target.write_text("original", encoding="utf-8") ++ ++ with tarfile.open(archive, "w:gz") as tar: ++ b = b"path traversal" ++ t = tarfile.TarInfo("../traversal.txt" if relative else target.as_posix()) ++ t.size = len(b) ++ tar.addfile(t, io.BytesIO(b)) ++ ++ has_data_filter = hasattr(tarfile, "data_filter") ++ # The stdlib implementation just strips the leading "/" from absolute paths ++ # and extracts them relative to the target directory (except for Windows). ++ # We do not care and raise an error. ++ raises = ( ++ relative ++ or WINDOWS ++ or not has_data_filter ++ or sys.version_info[:3] in {(3, 10, 12), (3, 11, 4)} ++ ) ++ exceptions: tuple[type[Exception], ...] ++ if has_data_filter: ++ if relative: ++ exceptions = (tarfile.OutsideDestinationError, ValueError) ++ else: ++ exceptions = (tarfile.AbsolutePathError, ValueError) ++ else: ++ # tarfile.OutsideDestinationError does not exist ++ exceptions = (ValueError,) ++ ++ with pytest.raises(exceptions) if raises else contextlib.nullcontext(): ++ extractall(source=archive, dest=dest, zip=False) ++ ++ if existing: ++ assert target.exists() ++ assert target.read_text(encoding="utf-8") == "original" ++ else: ++ assert not target.exists() ++ if not raises: ++ # check that expected location exists, otherwise we have to check ++ # that there is no traversal in an unexpected location ++ assert (dest / target.as_posix().lstrip("/")).exists() ++ ++ ++@pytest.mark.parametrize("link_type", [tarfile.SYMTYPE, tarfile.LNKTYPE]) ++@pytest.mark.parametrize("relative", [False, True]) ++@pytest.mark.parametrize("existing", [False, True]) ++def test_extractall_sdist_no_symlink_path_traversal( ++ tmp_path: Path, link_type: bytes, relative: bool, existing: bool ++) -> None: ++ import io ++ import tarfile ++ ++ archive = tmp_path / "traversal.tar.gz" ++ dest = tmp_path / "dest" ++ dest.mkdir() ++ ++ target = tmp_path / "traversal.txt" ++ if existing: ++ target.write_text("original", encoding="utf-8") ++ ++ with tarfile.open(archive, "w:gz") as tar: ++ # We use a link in a subdirectory to test the difference ++ # between symlinks and hardlinks: ++ # symlinks are relative to the directory of the symlink, ++ # while hardlinks are relative to the root of the archive ++ s = tarfile.TarInfo("sub/link") ++ s.type = link_type ++ if relative: ++ s.linkname = ( ++ "../../traversal.txt" ++ if link_type == tarfile.SYMTYPE ++ else "../traversal.txt" ++ ) ++ else: ++ s.linkname = target.as_posix() ++ tar.addfile(s) ++ p = b"path traversal" ++ f = tarfile.TarInfo("sub/link") ++ f.size = len(p) ++ tar.addfile(f, io.BytesIO(p)) ++ ++ exceptions: tuple[type[Exception], ...] ++ if hasattr(tarfile, "data_filter"): ++ exceptions = ( ++ tarfile.AbsoluteLinkError, ++ tarfile.LinkOutsideDestinationError, ++ ValueError, ++ ) ++ else: ++ # tarfile.OutsideDestinationError does not exist ++ exceptions = (ValueError,) ++ ++ with pytest.raises(exceptions): ++ extractall(source=archive, dest=dest, zip=False) ++ ++ if existing: ++ assert target.exists() ++ assert target.read_text(encoding="utf-8") == "original" ++ else: ++ assert not target.exists() ++ ++ ++@pytest.mark.parametrize("existing", [False, True]) ++def test_extractall_wheel_no_path_traversal( ++ tmp_path: Path, wheel_with_path_traversal: Path, existing: bool ++) -> None: ++ """see also test_no_path_traversal in test_wheel_installer.py""" ++ dest = tmp_path / "dest" / "dir" ++ dest.mkdir(parents=True) ++ target = tmp_path / "traversal.txt" ++ if existing: ++ target.write_text("original", encoding="utf-8") ++ ++ extractall(source=wheel_with_path_traversal, dest=dest, zip=True) ++ ++ if existing: ++ assert target.exists() ++ assert target.read_text(encoding="utf-8") == "original" ++ else: ++ assert not target.exists() ++ ++ # target is "../.." but also check ".." just to be sure ++ assert not (dest.parent / "traversal.txt").exists() ++ ++ ++@pytest.mark.parametrize("existing", [False, True]) ++def test_extractall_wheel_no_path_traversal_via_symlink( ++ tmp_path: Path, wheel_with_path_traversal_via_symlink: Path, existing: bool ++) -> None: ++ """see also test_no_path_traversal_via_symlink in test_wheel_installer.py""" ++ dest = tmp_path / "dest" / "dir" ++ dest.mkdir(parents=True) ++ target_dir = tmp_path / "target" ++ target_dir.mkdir() ++ target = target_dir / "traversal.txt" ++ if existing: ++ target.write_text("original", encoding="utf-8") ++ ++ with pytest.raises(FileNotFoundError if WINDOWS else NotADirectoryError): ++ extractall(source=wheel_with_path_traversal_via_symlink, dest=dest, zip=True) ++ ++ assert target_dir.exists() ++ if existing: ++ assert target.exists() ++ assert target.read_text(encoding="utf-8") == "original" ++ else: ++ assert not target.exists() +-- +2.45.4 + diff --git a/SPECS/poetry/poetry.spec b/SPECS/poetry/poetry.spec index bb2c467f8a5..b6ec73df567 100644 --- a/SPECS/poetry/poetry.spec +++ b/SPECS/poetry/poetry.spec @@ -5,13 +5,14 @@ projects, ensuring you have the right stack everywhere.} Summary: Python dependency management and packaging made easy Name: %{pypi_name} Version: 1.8.5 -Release: 1%{?dist} +Release: 2%{?dist} License: MIT Vendor: Microsoft Corporation Distribution: Azure Linux URL: https://poetry.eustace.io/ Source0: https://github.com/python-poetry/poetry/archive/refs/tags/%{version}.tar.gz#/poetry-%{version}.tar.gz Patch0: CVE-2026-34591.patch +Patch1: CVE-2026-41140.patch # relax some too-strict dependencies that are specified in setup.py: # - importlib-metadata (either removed or too old in fedora) # - keyring (too new in fedora, but should be compatible) @@ -109,6 +110,9 @@ pip3 install --ignore-installed \ %{python3_sitelib}/%{pypi_name}-%{version}.dist-info/ %changelog +* Wed Apr 29 2026 Azure Linux Security Servicing Account - 1.8.5-2 +- Patch for CVE-2026-41140 + * Tue Apr 07 2026 Azure Linux Security Servicing Account - 1.8.5-1 - Upgrade to version 1.8.5 - Patch for CVE-2026-34591