Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-poetry for openSUSE:Factory checked in at 2026-04-15 16:07:58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-poetry (Old) and /work/SRC/openSUSE:Factory/.python-poetry.new.21863 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-poetry" Wed Apr 15 16:07:58 2026 rev:42 rq:1347109 version:2.3.4 Changes: -------- --- /work/SRC/openSUSE:Factory/python-poetry/python-poetry.changes 2026-03-31 15:24:24.517617841 +0200 +++ /work/SRC/openSUSE:Factory/.python-poetry.new.21863/python-poetry.changes 2026-04-15 16:14:38.511838572 +0200 @@ -1,0 +2,11 @@ +Sun Apr 12 18:16:23 UTC 2026 - Dirk Müller <[email protected]> + +- update to 2.3.4: + * Fix a performance regression in the wheel installer that was + introduced in Poetry 2.3.3 (#10821). + * Fix a path traversal vulnerability in sdist extraction on + Python 3.10.0-3.10.12 and 3.11.0-3.11.4 that could allow + malicious tarball files to write files outside the target + directory (#10837). + +------------------------------------------------------------------- Old: ---- poetry-2.3.3.tar.gz New: ---- poetry-2.3.4.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-poetry.spec ++++++ --- /var/tmp/diff_new_pack.PnqXhi/_old 2026-04-15 16:14:39.235868160 +0200 +++ /var/tmp/diff_new_pack.PnqXhi/_new 2026-04-15 16:14:39.235868160 +0200 @@ -27,7 +27,7 @@ %{?sle15_python_module_pythons} Name: python-poetry%{psuffix} -Version: 2.3.3 +Version: 2.3.4 Release: 0 Summary: Python dependency management and packaging License: MIT @@ -46,6 +46,7 @@ Requires: python-filelock >= 3.8.0 # /cachecontrol[filecache] Requires: (python-cleo >= 2.1.0 with python-cleo < 3.0.0) +Requires: python-installer >= 0.7.0 Requires: python-packaging >= 24.2 Requires: python-pbs-installer >= 2025.6.10 Requires: python-trove-classifiers >= 2022.5.19 @@ -53,7 +54,6 @@ Requires: (python-dulwich >= 0.25.0 with python-dulwich < 2) Requires: (python-fastjsonschema >= 2.18.0 with python-fastjsonschema < 3.0.0) Requires: (python-findpython >= 0.6.2 with python-findpython < 0.8.0) -Requires: (python-installer >= 0.7.0 with python-installer < 0.8.0) Requires: (python-keyring >= 25.1.0 with python-keyring < 26.0.0) Requires: (python-pkginfo >= 1.12 with python-pkginfo < 2.0) Requires: (python-platformdirs >= 3.0.0 with python-platformdirs < 5) ++++++ poetry-2.3.3.tar.gz -> poetry-2.3.4.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/poetry-2.3.3/CHANGELOG.md new/poetry-2.3.4/CHANGELOG.md --- old/poetry-2.3.3/CHANGELOG.md 2026-03-29 14:13:27.000000000 +0200 +++ new/poetry-2.3.4/CHANGELOG.md 2026-04-12 17:09:54.000000000 +0200 @@ -1,5 +1,13 @@ # Change Log +## [2.3.4] - 2026-04-12 + +### Fixed + +- Fix a performance regression in the wheel installer that was introduced in Poetry 2.3.3 ([#10821](https://github.com/python-poetry/poetry/pull/10821)). +- Fix a path traversal vulnerability in sdist extraction on Python 3.10.0-3.10.12 and 3.11.0-3.11.4 that could allow malicious tarball files to write files outside the target directory ([#10837](https://github.com/python-poetry/poetry/pull/10837)). + + ## [2.3.3] - 2026-03-29 ### Fixed @@ -2693,7 +2701,8 @@ -[Unreleased]: https://github.com/python-poetry/poetry/compare/2.3.3...main +[Unreleased]: https://github.com/python-poetry/poetry/compare/2.3.4...main +[2.3.4]: https://github.com/python-poetry/poetry/releases/tag/2.3.4 [2.3.3]: https://github.com/python-poetry/poetry/releases/tag/2.3.3 [2.3.2]: https://github.com/python-poetry/poetry/releases/tag/2.3.2 [2.3.1]: https://github.com/python-poetry/poetry/releases/tag/2.3.1 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/poetry-2.3.3/pyproject.toml new/poetry-2.3.4/pyproject.toml --- old/poetry-2.3.3/pyproject.toml 2026-03-29 14:13:27.000000000 +0200 +++ new/poetry-2.3.4/pyproject.toml 2026-04-12 17:09:54.000000000 +0200 @@ -1,6 +1,6 @@ [project] name = "poetry" -version = "2.3.3" +version = "2.3.4" description = "Python dependency management and packaging made easy." requires-python = ">=3.10,<4.0" dependencies = [ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/poetry-2.3.3/src/poetry/installation/wheel_installer.py new/poetry-2.3.4/src/poetry/installation/wheel_installer.py --- old/poetry-2.3.3/src/poetry/installation/wheel_installer.py 2026-03-29 14:13:27.000000000 +0200 +++ new/poetry-2.3.4/src/poetry/installation/wheel_installer.py 2026-04-12 17:09:54.000000000 +0200 @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import os import platform import sys @@ -14,7 +15,6 @@ from poetry.__version__ import __version__ from poetry.utils._compat import WINDOWS -from poetry.utils._compat import is_relative_to logger = logging.getLogger(__name__) @@ -45,14 +45,20 @@ from installer.utils import copyfileobj_with_hashing from installer.utils import make_file_executable - target_dir = Path(self.scheme_dict[scheme]).resolve() - target_path = (target_dir / path).resolve() + # See https://docs.python.org/3/library/zipfile.html#zipfile.Path: + # When handling untrusted archives, + # consider resolving filenames using os.path.abspath() + # and checking against the target directory with os.path.commonpath(). + # + # 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()! + target_dir = Path(os.path.abspath(self.scheme_dict[scheme])) + target_path = Path(os.path.abspath(target_dir / path)) - # Use is_relative_to() instead of Path.is_relative_to() - # because the latter does not work if one of both paths - # has a Windows long path prefix and the other path has not. - # (A long path prefix may be added when calling resolve().) - if not is_relative_to(target_path, target_dir): + if not target_path.is_relative_to(target_dir): raise ValueError( f"Attempting to write {path} outside of the target directory\n" f"Target directory: {target_dir}\n" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/poetry-2.3.3/src/poetry/utils/helpers.py new/poetry-2.3.4/src/poetry/utils/helpers.py --- old/poetry-2.3.3/src/poetry/utils/helpers.py 2026-03-29 14:13:27.000000000 +0200 +++ new/poetry-2.3.4/src/poetry/utils/helpers.py 2026-04-12 17:09:54.000000000 +0200 @@ -415,7 +415,7 @@ else: # These versions of python shipped with a broken tarfile data_filter, per # https://github.com/python/cpython/issues/107845. - broken_tarfile_filter = {(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") @@ -423,4 +423,37 @@ ): 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 -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/poetry-2.3.3/tests/conftest.py new/poetry-2.3.4/tests/conftest.py --- old/poetry-2.3.3/tests/conftest.py 2026-03-29 14:13:27.000000000 +0200 +++ new/poetry-2.3.4/tests/conftest.py 2026-04-12 17:09:54.000000000 +0200 @@ -1047,3 +1047,85 @@ return bin_dir return register + + [email protected](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 + + [email protected](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 -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/poetry-2.3.3/tests/installation/test_wheel_installer.py new/poetry-2.3.4/tests/installation/test_wheel_installer.py --- old/poetry-2.3.3/tests/installation/test_wheel_installer.py 2026-03-29 14:13:27.000000000 +0200 +++ new/poetry-2.3.4/tests/installation/test_wheel_installer.py 2026-04-12 17:09:54.000000000 +0200 @@ -10,6 +10,7 @@ from poetry.core.constraints.version import parse_constraint from poetry.installation.wheel_installer import WheelInstaller +from poetry.utils._compat import WINDOWS from poetry.utils.env import MockEnv @@ -21,7 +22,7 @@ @pytest.fixture def env(tmp_path: Path) -> MockEnv: - return MockEnv(path=tmp_path) + return MockEnv(path=tmp_path / "env") @pytest.fixture(scope="module") @@ -97,35 +98,49 @@ assert (Path(env.paths["purelib"]) / "demo").exists() [email protected] -def wheel_with_path_traversal(tmp_path: Path) -> Path: - import zipfile [email protected]("existing", [False, True]) +def test_no_path_traversal( + env: MockEnv, wheel_with_path_traversal: Path, existing: bool +) -> None: + """see also test_extractall_wheel_no_path_traversal in test_helpers.py""" + target = env.path.parent / "traversal.txt" + if existing: + target.write_text("original", encoding="utf-8") + installer = WheelInstaller(env) + with pytest.raises(ValueError): + installer.install(wheel_with_path_traversal) - 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) + if existing: + assert target.exists() + assert target.read_text(encoding="utf-8") == "original" + else: + assert not target.exists() - return wheel [email protected]("existing", [False, True]) +def test_no_path_traversal_via_symlink( + tmp_path: Path, + env: MockEnv, + wheel_with_path_traversal_via_symlink: Path, + existing: bool, +) -> None: + """see also test_extractall_wheel_no_path_traversal_via_symlink + in test_helpers.py""" + target_dir = tmp_path / "target" + target_dir.mkdir() + target = target_dir / "traversal.txt" + if existing: + target.write_text("original", encoding="utf-8") -def test_path_traversal(env: MockEnv, wheel_with_path_traversal: Path) -> None: installer = WheelInstaller(env) - with pytest.raises(ValueError): - installer.install(wheel_with_path_traversal) - assert not (env.path.parent / "traversal.txt").exists() + with pytest.raises(FileNotFoundError if WINDOWS else NotADirectoryError): + installer.install(wheel_with_path_traversal_via_symlink) + + traversal_link = Path(env.paths["purelib"]) / "symlink" / "traversal_link" + assert traversal_link.exists() + assert not traversal_link.is_symlink() # not even extracted as symlink + assert target_dir.exists() + if existing: + assert target.read_text(encoding="utf-8") == "original" + else: + assert not list(target_dir.iterdir()) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/poetry-2.3.3/tests/utils/test_helpers.py new/poetry-2.3.4/tests/utils/test_helpers.py --- old/poetry-2.3.3/tests/utils/test_helpers.py 2026-03-29 14:13:27.000000000 +0200 +++ new/poetry-2.3.4/tests/utils/test_helpers.py 2026-04-12 17:09:54.000000000 +0200 @@ -1,7 +1,10 @@ from __future__ import annotations import base64 +import contextlib import re +import sys +import tarfile from pathlib import Path from typing import TYPE_CHECKING @@ -13,10 +16,12 @@ from poetry.core.utils.helpers import parse_requires from requests.exceptions import ChunkedEncodingError +from poetry.utils._compat import WINDOWS from poetry.utils.helpers import Downloader from poetry.utils.helpers import HTTPRangeRequestSupportedError from poetry.utils.helpers import download_file from poetry.utils.helpers import ensure_path +from poetry.utils.helpers import extractall from poetry.utils.helpers import get_file_hash from poetry.utils.helpers import get_highest_priority_hash_type @@ -341,3 +346,165 @@ path.mkdir() assert ensure_path(path=path, is_directory=True) is path + + [email protected]("relative", [False, True]) [email protected]("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() + + [email protected]("link_type", [tarfile.SYMTYPE, tarfile.LNKTYPE]) [email protected]("relative", [False, True]) [email protected]("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() + + [email protected]("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() + + [email protected]("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()
