Skip to content
Open
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
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.
46 changes: 43 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,33 @@ 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)

#: Filter errors meaning "this member would land outside of the destination".
#: They are raised in place of the ``UnsafeMember`` that ``_resolve_dest``
#: used to raise, so callers catching ``UnsafeMember``/``DistutilsError``
#: would otherwise miss the abort. Empty on interpreters without the filter,
#: where the filtering branch is never taken.
_OUTSIDE_DEST_ERRORS = tuple(
err
for err in (
getattr(tarfile, 'OutsideDestinationError', None),
getattr(tarfile, 'AbsolutePathError', None),
)
if err is not 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 +247,20 @@ 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.
try:
member = _DATA_FILTER(member, extract_dir)
except _OUTSIDE_DEST_ERRORS as exc:
# A link resolving to a member whose own name escapes the
# destination: report it the way the pre-filter code did,
# so callers catching UnsafeMember (a DistutilsError)
# still see the abort rather than a tarfile error.
raise UnsafeMember(str(exc)) from exc

final_dst = progress_filter(name, prelim_dst)
if not final_dst:
continue
Expand Down
113 changes: 113 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,115 @@ 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 _make_tarfile_with_escaping_link(path, link_type):
"""Archive whose 'ok' link resolves to the escaping member '../evil'."""
with tarfile.open(path, mode='w') as tar:
link = tarfile.TarInfo('ok')
link.type = link_type
link.linkname = '../evil'
tar.addfile(link)

data = b'evil payload'
evil = tarfile.TarInfo('../evil')
evil.size = len(data)
tar.addfile(evil, io.BytesIO(data))
return str(path)


@pytest.mark.parametrize('link_type', [tarfile.LNKTYPE, tarfile.SYMTYPE])
def test_iter_open_tar_reports_escaping_link_as_unsafe(tmp_path, link_type):
"""
A link resolving to a member that escapes the destination is reported as
``UnsafeMember`` rather than as the tarfile filter's own error, so callers
catching ``UnsafeMember``/``DistutilsError`` still see the abort.
"""
archive = _make_tarfile_with_escaping_link(tmp_path / 'escape.tar', link_type)
dest = tmp_path / 'dest'
dest.mkdir()

with tarfile.open(archive) as tar:
with pytest.raises(archive_util.UnsafeMember):
list(
archive_util._iter_open_tar(
tar, str(dest), archive_util.default_filter
)
)


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'