PYSEC-2026-2890
Vulnerability from pysec - Published: 2026-07-13 15:02 - Updated: 2026-07-13 16:05Summary
The extractall() function in src/poetry/utils/helpers.py:410-426 extracts sdist tarballs without path traversal protection on Python versions where tarfile.data_filter is unavailable. Considering only Python versions which are still supported by Poetry, these are 3.10.0 - 3.10.12 and 3.11.0 - 3.11.4.
Impact
Arbitrary file write (path traversal) from untrusted sdist content.
In practice, the impact is low because an attacker who exploits this vulnerability can as well include arbitrary code in a setup.py, which will be executed when the sdist is built after tar extraction. In other words, a malicious sdist can write arbitrary files by design. However, since it is unexpected and not by design that the file write already happens during tar extraction, this is still considered a vulnerability.
On Python 3.11.2 (Debian Bookworm default, directly tested), a crafted sdist with ../../ tar member paths writes files outside the intended extraction directory. The traversal occurs during metadata resolution (poetry add --lock), before the build backend is run.
Affected Environments:
- Python 3.10.0 through 3.10.12 (inclusive): tarfile.data_filter absent or broken
- Python 3.11.0 through 3.11.4 (inclusive): tarfile.data_filter absent or broken
- Debian Bookworm: Python 3.11.2 (default)
- Ubuntu 22.04 LTS: Python 3.10.6 (default)
Patches
Versions 2.3.4 and newer of Poetry ensure that paths are inside the target directory.
Root Cause
File: src/poetry/utils/helpers.py, lines 410-426:
def extractall(source: Path, dest: Path, zip: bool) -> None:
"""Extract all members from either a zip or tar archive."""
if zip:
with zipfile.ZipFile(source) as archive:
archive.extractall(dest)
else:
broken_tarfile_filter = {(3, 9, 17), (3, 10, 12), (3, 11, 4)}
with tarfile.open(source) as archive:
if (
hasattr(tarfile, "data_filter")
and sys.version_info[:3] not in broken_tarfile_filter
):
archive.extractall(dest, filter="data")
else:
archive.extractall(dest) # <-- NO FILTER: path traversal
On Python versions without a working tarfile.data_filter, the else branch at line 426 calls tarfile.extractall() without any filter or path validation. This enables three attack vectors:
- Direct path traversal: Tar members with
../../path components write files outside the extraction directory. - Symlink traversal: A symlink member pointing outside dest, followed by a file written through that symlink, escapes the boundary.
- Hardlink attacks: Hardlink members can read arbitrary files (same inode) or overwrite targets outside dest.
Call Sites
This function is called from two locations:
-
src/poetry/installation/chef.py:104(_prepare_sdist): Duringpoetry install/poetry addwhen building a package from sdist. Only triggered when the executor is enabled (actual installation). -
src/poetry/inspection/info.py:322(_from_sdist_file): During dependency resolution (poetry lock/poetry add). This path is reached when the sdist'sPKG-INFOlacksRequires-Distmetadata, forcing Poetry to extract the archive (and afterwards build the package).
Suggested Fix
Apply path validation in the else branch, covering direct traversal, symlinks, and hardlinks:
def extractall(source: Path, dest: Path, zip: bool) -> None:
"""Extract all members from either a zip or tar archive."""
if zip:
with zipfile.ZipFile(source) as archive:
archive.extractall(dest)
else:
broken_tarfile_filter = {(3, 9, 17), (3, 10, 12), (3, 11, 4)}
with tarfile.open(source) as archive:
if (
hasattr(tarfile, "data_filter")
and sys.version_info[:3] not in broken_tarfile_filter
):
archive.extractall(dest, filter="data")
else:
# Validate all member paths before extraction
dest_resolved = dest.resolve()
safe_members = []
for member in archive.getmembers():
member_path = (dest_resolved / member.name).resolve()
if not member_path.is_relative_to(dest_resolved):
raise ValueError(
f"Refusing to extract {member.name}: "
f"would write outside {dest}"
)
if member.issym():
link_target = (member_path.parent / member.linkname).resolve()
if not link_target.is_relative_to(dest_resolved):
raise ValueError(
f"Refusing symlink {member.name}: "
f"target {member.linkname} outside {dest}"
)
elif member.islnk():
link_target = (dest_resolved / member.linkname).resolve()
if not link_target.is_relative_to(dest_resolved):
raise ValueError(
f"Refusing hardlink {member.name}: "
f"target {member.linkname} outside {dest}"
)
safe_members.append(member)
archive.extractall(dest, members=safe_members)
| Name | purl | poetry | pkg:pypi/poetry |
|---|
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "poetry",
"purl": "pkg:pypi/poetry"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.3.4"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"0.1.0",
"0.10.0",
"0.10.0a0",
"0.10.0a1",
"0.10.0a2",
"0.10.0a3",
"0.10.1",
"0.10.2",
"0.10.3",
"0.11.0",
"0.11.0a0",
"0.11.0a1",
"0.11.0a2",
"0.11.0a3",
"0.11.0a4",
"0.11.1",
"0.11.2",
"0.11.3",
"0.11.4",
"0.11.5",
"0.12.0",
"0.12.0a0",
"0.12.0a1",
"0.12.0a2",
"0.12.0a3",
"0.12.0a4",
"0.12.0a5",
"0.12.1",
"0.12.10",
"0.12.11",
"0.12.12",
"0.12.13",
"0.12.14",
"0.12.15",
"0.12.16",
"0.12.17",
"0.12.2",
"0.12.3",
"0.12.4",
"0.12.5",
"0.12.6",
"0.12.7",
"0.12.8",
"0.12.9",
"0.2.0",
"0.3.0",
"0.4.0",
"0.4.0.post1",
"0.4.1",
"0.4.2",
"0.5.0",
"0.5.0b1",
"0.5.0b2",
"0.6.0",
"0.6.1",
"0.6.2",
"0.6.3",
"0.6.3b1",
"0.6.3b2",
"0.6.3b3",
"0.6.3b4",
"0.6.3b5",
"0.6.3b6",
"0.6.3b7",
"0.6.4",
"0.6.4b1",
"0.6.5",
"0.7.0",
"0.7.0b1",
"0.7.0b2",
"0.7.0b3",
"0.7.0b4",
"0.7.1",
"0.8.0",
"0.8.0a0",
"0.8.0a1",
"0.8.0a2",
"0.8.0a3",
"0.8.0a4",
"0.8.1",
"0.8.1a0",
"0.8.2",
"0.8.3",
"0.8.4",
"0.8.5",
"0.8.5a0",
"0.8.6",
"0.9.0",
"0.9.0a0",
"0.9.0a1",
"0.9.0a2",
"0.9.0a3",
"0.9.1",
"1.0.0",
"1.0.0a0",
"1.0.0a1",
"1.0.0a2",
"1.0.0a3",
"1.0.0a4",
"1.0.0a5",
"1.0.0b1",
"1.0.0b2",
"1.0.0b3",
"1.0.0b4",
"1.0.0b5",
"1.0.0b6",
"1.0.0b7",
"1.0.0b8",
"1.0.0b9",
"1.0.1",
"1.0.10",
"1.0.2",
"1.0.3",
"1.0.4",
"1.0.5",
"1.0.6",
"1.0.7",
"1.0.8",
"1.0.9",
"1.1.0",
"1.1.0a1",
"1.1.0a2",
"1.1.0a3",
"1.1.0b1",
"1.1.0b2",
"1.1.0b3",
"1.1.0b4",
"1.1.0rc1",
"1.1.1",
"1.1.10",
"1.1.11",
"1.1.12",
"1.1.13",
"1.1.14",
"1.1.15",
"1.1.2",
"1.1.3",
"1.1.4",
"1.1.5",
"1.1.6",
"1.1.7",
"1.1.8",
"1.1.9",
"1.2.0",
"1.2.0a1",
"1.2.0a2",
"1.2.0b1",
"1.2.0b2",
"1.2.0b3",
"1.2.0rc1",
"1.2.0rc2",
"1.2.1",
"1.2.2",
"1.3.0",
"1.3.1",
"1.3.2",
"1.4.0",
"1.4.1",
"1.4.2",
"1.5.0",
"1.5.1",
"1.6.0",
"1.6.1",
"1.7.0",
"1.7.1",
"1.8.0",
"1.8.1",
"1.8.2",
"1.8.3",
"1.8.4",
"1.8.5",
"2.0.0",
"2.0.1",
"2.1.0",
"2.1.1",
"2.1.2",
"2.1.3",
"2.1.4",
"2.2.0",
"2.2.1",
"2.3.0",
"2.3.1",
"2.3.2",
"2.3.3"
]
}
],
"aliases": [
"CVE-2026-41140",
"GHSA-73h3-mf4w-8647"
],
"details": "### Summary\n\nThe `extractall()` function in `src/poetry/utils/helpers.py:410-426` extracts sdist tarballs without path traversal protection on Python versions where `tarfile.data_filter` is unavailable. Considering only Python versions which are still supported by Poetry, these are 3.10.0 - 3.10.12 and 3.11.0 - 3.11.4.\n\n### Impact\n\nArbitrary file write (path traversal) from untrusted sdist content.\n\n**In practice, the impact is low** because an attacker who exploits this vulnerability can as well include arbitrary code in a `setup.py`, which will be executed when the sdist is built after tar extraction. In other words, a malicious sdist can write arbitrary files by design. However, since it is unexpected and not by design that the file write already happens during tar extraction, this is still considered a vulnerability.\n\nOn Python 3.11.2 (Debian Bookworm default, directly tested), a crafted sdist with `../../` tar member paths writes files outside the intended extraction directory. The traversal occurs during metadata resolution (`poetry add --lock`), before the build backend is run.\n\nAffected Environments: \n- **Python 3.10.0 through 3.10.12** (inclusive): `tarfile.data_filter` absent or broken\n- **Python 3.11.0 through 3.11.4** (inclusive): `tarfile.data_filter` absent or broken\n- **Debian Bookworm**: Python 3.11.2 (default)\n- **Ubuntu 22.04 LTS**: Python 3.10.6 (default)\n\n### Patches\n\nVersions 2.3.4 and newer of Poetry ensure that paths are inside the target directory.\n\n### Root Cause\n\nFile: `src/poetry/utils/helpers.py`, lines 410-426:\n\n```python\ndef extractall(source: Path, dest: Path, zip: bool) -\u003e None:\n \"\"\"Extract all members from either a zip or tar archive.\"\"\"\n if zip:\n with zipfile.ZipFile(source) as archive:\n archive.extractall(dest)\n else:\n broken_tarfile_filter = {(3, 9, 17), (3, 10, 12), (3, 11, 4)}\n with tarfile.open(source) as archive:\n if (\n hasattr(tarfile, \"data_filter\")\n and sys.version_info[:3] not in broken_tarfile_filter\n ):\n archive.extractall(dest, filter=\"data\")\n else:\n archive.extractall(dest) # \u003c-- NO FILTER: path traversal\n```\n\nOn Python versions without a working `tarfile.data_filter`, the `else` branch at line 426 calls `tarfile.extractall()` without any filter or path validation. This enables three attack vectors:\n\n1. **Direct path traversal**: Tar members with `../../` path components write files outside the extraction directory.\n2. **Symlink traversal**: A symlink member pointing outside dest, followed by a file written through that symlink, escapes the boundary.\n3. **Hardlink attacks**: Hardlink members can read arbitrary files (same inode) or overwrite targets outside dest.\n\n#### Call Sites\n\nThis function is called from two locations:\n\n1. **`src/poetry/installation/chef.py:104`** (`_prepare_sdist`): During `poetry install` / `poetry add` when building a package from sdist. Only triggered when the executor is enabled (actual installation).\n\n2. **`src/poetry/inspection/info.py:322`** (`_from_sdist_file`): During dependency resolution (`poetry lock` / `poetry add`). This path is reached when the sdist\u0027s `PKG-INFO` lacks `Requires-Dist` metadata, forcing Poetry to extract the archive (and afterwards build the package).\n\n### Suggested Fix\n\nApply path validation in the `else` branch, covering direct traversal, symlinks, and hardlinks:\n\n```python\ndef extractall(source: Path, dest: Path, zip: bool) -\u003e None:\n \"\"\"Extract all members from either a zip or tar archive.\"\"\"\n if zip:\n with zipfile.ZipFile(source) as archive:\n archive.extractall(dest)\n else:\n broken_tarfile_filter = {(3, 9, 17), (3, 10, 12), (3, 11, 4)}\n with tarfile.open(source) as archive:\n if (\n hasattr(tarfile, \"data_filter\")\n and sys.version_info[:3] not in broken_tarfile_filter\n ):\n archive.extractall(dest, filter=\"data\")\n else:\n # Validate all member paths before extraction\n dest_resolved = dest.resolve()\n safe_members = []\n for member in archive.getmembers():\n member_path = (dest_resolved / member.name).resolve()\n if not member_path.is_relative_to(dest_resolved):\n raise ValueError(\n f\"Refusing to extract {member.name}: \"\n f\"would write outside {dest}\"\n )\n if member.issym():\n link_target = (member_path.parent / member.linkname).resolve()\n if not link_target.is_relative_to(dest_resolved):\n raise ValueError(\n f\"Refusing symlink {member.name}: \"\n f\"target {member.linkname} outside {dest}\"\n )\n elif member.islnk():\n link_target = (dest_resolved / member.linkname).resolve()\n if not link_target.is_relative_to(dest_resolved):\n raise ValueError(\n f\"Refusing hardlink {member.name}: \"\n f\"target {member.linkname} outside {dest}\"\n )\n safe_members.append(member)\n archive.extractall(dest, members=safe_members)\n```",
"id": "PYSEC-2026-2890",
"modified": "2026-07-13T16:05:36.936329Z",
"published": "2026-07-13T15:02:52.466566Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/python-poetry/poetry/security/advisories/GHSA-73h3-mf4w-8647"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-41140"
},
{
"type": "PACKAGE",
"url": "https://github.com/python-poetry/poetry"
},
{
"type": "WEB",
"url": "https://github.com/python-poetry/poetry/releases/tag/2.3.4"
},
{
"type": "PACKAGE",
"url": "https://pypi.org/project/poetry"
},
{
"type": "ADVISORY",
"url": "https://github.com/advisories/GHSA-73h3-mf4w-8647"
}
],
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N/E:U",
"type": "CVSS_V4"
}
],
"summary": "Poetry has Path Traversal in tar extraction on Python 3.10.0 - 3.10.12 and 3.11.0 - 3.11.4"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.