Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions newsfragments/+5328.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Extraction of tar archives now runs each member through tarfile's PEP 706
``data`` filter, so archive-supplied setuid/setgid/sticky bits and ownership
never reach the filesystem, and link resolution no longer relies on private
``tarfile`` APIs.
25 changes: 22 additions & 3 deletions setuptools/archive_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,10 @@ def _resolve_tar_file_or_dir(tar_obj, tar_member_obj):
base = posixpath.dirname(tar_member_obj.name)
linkpath = posixpath.join(base, linkpath)
linkpath = posixpath.normpath(linkpath)
tar_member_obj = tar_obj._getmember(linkpath)
# exact-name lookup, last occurrence wins; public API equivalent of
# the private TarFile._getmember(linkpath) (which lacks that guarantee)
matches = [m for m in tar_obj.getmembers() if m.name == linkpath]
tar_member_obj = matches[-1] if matches else None

is_file_or_dir = tar_member_obj is not None and (
tar_member_obj.isfile() or tar_member_obj.isdir()
Expand All @@ -206,10 +209,19 @@ def _resolve_tar_file_or_dir(tar_obj, tar_member_obj):
raise LookupError('Got unknown file type')


#: The PEP 706 "data" extraction filter, present on Python 3.12+ and on the
#: 3.10.12+/3.11.4+ security backports. On earlier micro releases it is
#: absent and extraction falls back to the legacy behavior.
_DATA_FILTER = getattr(tarfile, 'data_filter', None)


def _iter_open_tar(tar_obj, extract_dir, progress_filter):
"""Emit member-destination pairs from a tar archive."""
# don't do any chowning!
tar_obj.chown = lambda *args: None
if _DATA_FILTER is None:
# Without the PEP 706 filter, ownership supplied by the archive
# would be applied verbatim, so suppress it manually.
# don't do any chowning!
tar_obj.chown = lambda *args: None

with contextlib.closing(tar_obj):
for member in tar_obj:
Expand All @@ -221,6 +233,13 @@ def _iter_open_tar(tar_obj, extract_dir, progress_filter):
except LookupError:
continue

if _DATA_FILTER is not None:
# PEP 706: vet the member against the extraction directory
# and take its sanitized copy (high mode bits stripped,
# ownership cleared, special files rejected) before the
# member is written anywhere.
member = _DATA_FILTER(member, extract_dir)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With a hard link ok to a later member ../evil, this line raises tarfile.OutsideDestinationError where main raises UnsafeMember. Callers catching UnsafeMember or DistutilsError miss this abort.


final_dst = progress_filter(name, prelim_dst)
if not final_dst:
continue
Expand Down
78 changes: 78 additions & 0 deletions setuptools/tests/test_archive_util.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import io
import os
import tarfile
import zipfile

Expand Down Expand Up @@ -145,3 +146,80 @@ def test_resolve_dest_rejects_symlinked_escape(tmp_path):
archive_util._resolve_dest(str(target), 'sub/file.txt')

assert archive_util._resolve_dest(str(target), 'ok/file.txt')


def _make_tarfile_with_setuid(path):
with tarfile.open(path, mode='w') as tar:
data = b'echo hi'
info = tarfile.TarInfo('script.sh')
info.size = len(data)
info.mode = 0o4755 # setuid + rwxr-xr-x
tar.addfile(info, io.BytesIO(data))
return str(path)


def test_iter_open_tar_applies_data_filter(tmp_path):
"""
Each member handed to the driver is the tarfile data filter's sanitized
copy: high mode bits (setuid/setgid/sticky) are stripped and archive
ownership is cleared, so none of it reaches the filesystem (#5328).
"""
archive = _make_tarfile_with_setuid(tmp_path / 'setuid.tar')

with tarfile.open(archive) as tar:
members = list(
archive_util._iter_open_tar(
tar, str(tmp_path / 'dest'), archive_util.default_filter
)
)

assert len(members) == 1
member, dst = members[0]
assert member.mode & 0o7000 == 0
assert member.mode & 0o755 == 0o755
assert member.uid is None
assert member.gid is None
assert dst == os.path.join(str(tmp_path / 'dest'), 'script.sh')


@pytest.mark.skipif(os.name != 'posix', reason='POSIX permission bits required')
def test_unpack_tarfile_strips_high_mode_bits(tmp_path):
"""
A setuid regular file is extracted without the setuid bit (PEP 706).
"""
archive = _make_tarfile_with_setuid(tmp_path / 'setuid.tar')
target = tmp_path / 'dest'

archive_util.unpack_tarfile(archive, str(target))

extracted = (target / 'script.sh').stat().st_mode
assert extracted & 0o7000 == 0
assert extracted & 0o500 # still readable/executable


def test_unpack_tarfile_resolves_links_to_targets(tmp_path):
"""
Link members are materialized as copies of their archive-relative
targets, resolved through the public member listing (#5328).
"""
archive = tmp_path / 'links.tar'
with tarfile.open(archive, mode='w') as tar:
dir_info = tarfile.TarInfo('sub/')
dir_info.type = tarfile.DIRTYPE
tar.addfile(dir_info)

data = b'payload'
file_info = tarfile.TarInfo('sub/data.txt')
file_info.size = len(data)
tar.addfile(file_info, io.BytesIO(data))

file_link = tarfile.TarInfo('filelink')
file_link.type = tarfile.LNKTYPE
file_link.linkname = 'sub/data.txt'
tar.addfile(file_link)

target = tmp_path / 'dest'
archive_util.unpack_tarfile(archive, str(target))

assert (target / 'filelink').is_file()
assert (target / 'filelink').read_bytes() == b'payload'