Package: release.debian.org
Severity: normal
Tags: trixie
X-Debbugs-Cc: [email protected], [email protected]
Control: affects -1 + src:poetry
User: [email protected]
Usertags: pu

  * CVE-2026-34591: Wheel Path Traversal Leading to Arbitrary File Write
    (Closes: #1132609)
diffstat for poetry-2.1.2+dfsg poetry-2.1.2+dfsg

 changelog                                                               |    8 
 patches/0001-installer-fix-path-traversal-10792.patch                   |   90 
++++
 patches/0002-perf-use-os.path.abspath-instead-of-Path.resolve-108.patch |  185 
++++++++++
 patches/series                                                          |    2 
 4 files changed, 285 insertions(+)

diff -Nru poetry-2.1.2+dfsg/debian/changelog poetry-2.1.2+dfsg/debian/changelog
--- poetry-2.1.2+dfsg/debian/changelog  2025-04-04 02:49:58.000000000 +0300
+++ poetry-2.1.2+dfsg/debian/changelog  2026-07-03 18:25:04.000000000 +0300
@@ -1,3 +1,11 @@
+poetry (2.1.2+dfsg-1+deb13u1) trixie; urgency=medium
+
+  * Non-maintainer upload.
+  * CVE-2026-34591: Wheel Path Traversal Leading to Arbitrary File Write
+    (Closes: #1132609)
+
+ -- Adrian Bunk <[email protected]>  Fri, 03 Jul 2026 18:25:04 +0300
+
 poetry (2.1.2+dfsg-1) unstable; urgency=medium
 
   * New upstream version.
diff -Nru 
poetry-2.1.2+dfsg/debian/patches/0001-installer-fix-path-traversal-10792.patch 
poetry-2.1.2+dfsg/debian/patches/0001-installer-fix-path-traversal-10792.patch
--- 
poetry-2.1.2+dfsg/debian/patches/0001-installer-fix-path-traversal-10792.patch  
    1970-01-01 02:00:00.000000000 +0200
+++ 
poetry-2.1.2+dfsg/debian/patches/0001-installer-fix-path-traversal-10792.patch  
    2026-07-03 18:24:45.000000000 +0300
@@ -0,0 +1,90 @@
+From 6895943e9e2f9ce563ea51b984830fb87f4d7371 Mon Sep 17 00:00:00 2001
+From: Randy Döring <[email protected]>
+Date: Sun, 29 Mar 2026 10:24:17 +0200
+Subject: installer: fix path traversal (#10792)
+
+(cherry picked from commit ed59537ac3709cfbdbf95d957de801c13872991a)
+---
+ src/poetry/installation/wheel_installer.py |  9 +++-
+ tests/installation/test_wheel_installer.py | 48 ++++++++++++++++++++++
+ 2 files changed, 56 insertions(+), 1 deletion(-)
+
+diff --git a/src/poetry/installation/wheel_installer.py 
b/src/poetry/installation/wheel_installer.py
+index 57ee02d9..a086ca73 100644
+--- a/src/poetry/installation/wheel_installer.py
++++ b/src/poetry/installation/wheel_installer.py
+@@ -44,7 +44,14 @@ class WheelDestination(SchemeDictionaryDestination):
+         from installer.utils import copyfileobj_with_hashing
+         from installer.utils import make_file_executable
+ 
+-        target_path = Path(self.scheme_dict[scheme]) / path
++        target_dir = Path(self.scheme_dict[scheme]).resolve()
++        target_path = (target_dir / path).resolve()
++
++        if not target_path.is_relative_to(target_dir):
++            raise ValueError(
++                f"Attempting to write {path} outside of the target directory"
++            )
++
+         if target_path.exists():
+             # Contrary to the base library we don't raise an error here since 
it can
+             # break pkgutil-style and pkg_resource-style namespace packages.
+diff --git a/tests/installation/test_wheel_installer.py 
b/tests/installation/test_wheel_installer.py
+index 98e3f4cf..33e95dd9 100644
+--- a/tests/installation/test_wheel_installer.py
++++ b/tests/installation/test_wheel_installer.py
+@@ -81,3 +81,51 @@ def test_enable_bytecode_compilation(
+         assert not list(cache_dir.glob("*.opt-2.pyc"))
+     else:
+         assert not cache_dir.exists()
++
++
++def test_install_dir_is_symlink(tmp_path: Path, demo_wheel: Path) -> None:
++    target_dir = tmp_path / "target"
++    target_dir.mkdir()
++    symlink_dir = tmp_path / "symlink"
++    symlink_dir.symlink_to(target_dir, target_is_directory=True)
++
++    env = MockEnv(path=symlink_dir)
++
++    installer = WheelInstaller(env)
++    installer.install(demo_wheel)
++
++    assert (Path(env.paths["purelib"]) / "demo").exists()
++
++
[email protected]
++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)
++    with pytest.raises(ValueError):
++        installer.install(wheel_with_path_traversal)
++    assert not (env.path.parent / "traversal.txt").exists()
+-- 
+2.47.3
+
diff -Nru 
poetry-2.1.2+dfsg/debian/patches/0002-perf-use-os.path.abspath-instead-of-Path.resolve-108.patch
 
poetry-2.1.2+dfsg/debian/patches/0002-perf-use-os.path.abspath-instead-of-Path.resolve-108.patch
--- 
poetry-2.1.2+dfsg/debian/patches/0002-perf-use-os.path.abspath-instead-of-Path.resolve-108.patch
    1970-01-01 02:00:00.000000000 +0200
+++ 
poetry-2.1.2+dfsg/debian/patches/0002-perf-use-os.path.abspath-instead-of-Path.resolve-108.patch
    2026-07-03 18:24:45.000000000 +0300
@@ -0,0 +1,185 @@
+From bd6f54aec6e4f07dcf77ad9e8acee474e55cb06a Mon Sep 17 00:00:00 2001
+From: Randy Döring <[email protected]>
+Date: Sun, 12 Apr 2026 14:21:51 +0200
+Subject: perf: use `os.path.abspath()` instead of `Path.resolve()` (#10821)
+
+(cherry picked from commit a029a41ea4adb1b35e9d1ba4fe391cd1c77804d8)
+---
+ src/poetry/installation/wheel_installer.py |  15 ++-
+ tests/installation/test_wheel_installer.py | 101 +++++++++++++++++++--
+ 2 files changed, 108 insertions(+), 8 deletions(-)
+
+diff --git a/src/poetry/installation/wheel_installer.py 
b/src/poetry/installation/wheel_installer.py
+index a086ca73..c4fff3b1 100644
+--- a/src/poetry/installation/wheel_installer.py
++++ b/src/poetry/installation/wheel_installer.py
+@@ -1,6 +1,7 @@
+ from __future__ import annotations
+ 
+ import logging
++import os
+ import platform
+ import sys
+ 
+@@ -44,8 +45,18 @@ class WheelDestination(SchemeDictionaryDestination):
+         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))
+ 
+         if not target_path.is_relative_to(target_dir):
+             raise ValueError(
+diff --git a/tests/installation/test_wheel_installer.py 
b/tests/installation/test_wheel_installer.py
+index 33e95dd9..3ab60a59 100644
+--- a/tests/installation/test_wheel_installer.py
++++ b/tests/installation/test_wheel_installer.py
+@@ -10,6 +10,7 @@ import pytest
+ 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 @@ if TYPE_CHECKING:
+ 
+ @pytest.fixture
+ def env(tmp_path: Path) -> MockEnv:
+-    return MockEnv(path=tmp_path)
++    return MockEnv(path=tmp_path / "env")
+ 
+ 
+ @pytest.fixture(scope="module")
+@@ -97,14 +98,20 @@ def test_install_dir_is_symlink(tmp_path: Path, 
demo_wheel: Path) -> None:
+     assert (Path(env.paths["purelib"]) / "demo").exists()
+ 
+ 
[email protected]
+-def wheel_with_path_traversal(tmp_path: Path) -> Path:
[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.txt": 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"
+         ),
+@@ -124,8 +131,90 @@ def wheel_with_path_traversal(tmp_path: Path) -> Path:
+     return wheel
+ 
+ 
+-def test_path_traversal(env: MockEnv, wheel_with_path_traversal: Path) -> 
None:
[email protected]("existing", [False, True])
++def test_no_path_traversal(
++    env: MockEnv, wheel_with_path_traversal: Path, existing: bool
++) -> None:
++    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)
+-    assert not (env.path.parent / "traversal.txt").exists()
++
++    if existing:
++        assert target.exists()
++        assert target.read_text(encoding="utf-8") == "original"
++    else:
++        assert not target.exists()
++
++
[email protected](params=[False, True])  # relative path
++def wheel_with_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
++
++
[email protected]("existing", [False, True])
++def test_no_path_traversal_via_symlink(
++    tmp_path: Path, env: MockEnv, wheel_with_symlink: Path, existing: bool
++) -> None:
++    target_dir = tmp_path / "target"
++    target_dir.mkdir()
++    target = target_dir / "traversal.txt"
++    if existing:
++        target.write_text("original", encoding="utf-8")
++
++    installer = WheelInstaller(env)
++    with pytest.raises(FileNotFoundError if WINDOWS else NotADirectoryError):
++        installer.install(wheel_with_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())
+-- 
+2.47.3
+
diff -Nru poetry-2.1.2+dfsg/debian/patches/series 
poetry-2.1.2+dfsg/debian/patches/series
--- poetry-2.1.2+dfsg/debian/patches/series     2025-04-04 02:49:29.000000000 
+0300
+++ poetry-2.1.2+dfsg/debian/patches/series     2026-07-03 18:25:04.000000000 
+0300
@@ -2,3 +2,5 @@
 fix-env-manager.patch
 fix-tests.patch
 increase-sleep-time-in-threading-test.patch
+0001-installer-fix-path-traversal-10792.patch
+0002-perf-use-os.path.abspath-instead-of-Path.resolve-108.patch

Reply via email to