Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
387 changes: 387 additions & 0 deletions SPECS/poetry/CVE-2026-41140.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,387 @@
From a63b68b2f8e952a7d72c39a06817b5fbddd22c4c Mon Sep 17 00:00:00 2001
From: AllSpark <allspark@microsoft.com>
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 <azurelinux-security@microsoft.com>
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

Loading
Loading