Origin: https://github.com/python/cpython/commit/eee3ddf0ca10283cc7fea724aae9cd8665f8d15e
From 5d31df767e4230c1f5a4076b5aa239df986ecf49 Mon Sep 17 00:00:00 2001
From: "Miss Islington (bot)"
<31488909+miss-islington@users.noreply.github.com>
Date: Mon, 29 Jun 2026 21:11:44 +0200
Subject: [PATCH 1/2] [3.13] gh-151987: Pass filter_function to
`TarFile._extract_one()` during `.extract()` (GH-151988) (#152610)
(cherry picked from commit 7ccdbaba2c54250a70d7f25632152df7655a5e0a)
Co-authored-by: Petr Viktorin <encukou@gmail.com>
Co-authored-by: Seth Michael Larson <seth@python.org>
Lib/tarfile.py | 3 +-
Lib/test/test_tarfile.py | 92 +++++++++++++++++++
...-06-23-14-19-30.gh-issue-151987.8mNIMf.rst | 2 +
3 files changed, 96 insertions(+), 1 deletion(-)
create mode 100644 Misc/NEWS.d/next/Security/2026-06-23-14-19-30.gh-issue-151987.8mNIMf.rst
@@ -2401,7 +2401,8 @@ class TarFile(object):
tarinfo, unfiltered = self._get_extract_tarinfo(
member, filter_function, path)
if tarinfo is not None:
- self._extract_one(tarinfo, path, set_attrs, numeric_owner)
+ self._extract_one(tarinfo, path, set_attrs, numeric_owner,
+ filter_function=filter_function)
def _get_extract_tarinfo(self, member, filter_function, path):
"""Get (filtered, unfiltered) TarInfos from *member*
@@ -4135,6 +4135,98 @@ class TestExtractionFilters(unittest.TestCase):
st_mode = cc.outerdir.stat().st_mode
self.assertNotEqual(st_mode & 0o777, 0o777)
+ @symlink_test
+ @unittest.skipUnless(hasattr(os, 'chown'), "missing os.chown")
+ @unittest.skipUnless(hasattr(os, 'lchown'), "missing os.lchown")
+ @unittest.skipUnless(hasattr(os, 'geteuid'), "missing os.geteuid")
+ def test_chown_links_on_extract(self):
+ for link_type in (tarfile.SYMTYPE, tarfile.LNKTYPE):
+ with ArchiveMaker() as arc:
+ arc.add("test.txt",
+ uid=1337, gid=1337, uname="", gname="", mode='-rwxr-xr-x')
+ arc.add("link",
+ type=link_type,
+ linkname='test.txt',
+ uid=1337, gid=1337, uname="", gname="", mode='-rwxr-xr-x')
+
+ with (
+ os_helper.temp_dir() as tmpdir,
+ arc.open() as tar,
+ unittest.mock.patch("os.chown") as mock_chown,
+ unittest.mock.patch("os.lchown") as mock_lchown,
+ unittest.mock.patch("os.geteuid") as mock_geteuid,
+ ):
+ # Set UID to 0 so chown() is attempted.
+ mock_geteuid.return_value = 0
+ tar.extract("link", path=tmpdir, filter='data')
+ extract_path = os.path.join(tmpdir, "link")
+
+ if link_type == tarfile.SYMTYPE:
+ mock_chown.assert_not_called()
+ mock_lchown.assert_called_once_with(extract_path, -1, -1)
+ else:
+ mock_chown.assert_has_calls([
+ unittest.mock.call(extract_path, -1, -1),
+ unittest.mock.call(extract_path, -1, -1)
+ ])
+ mock_lchown.assert_not_called()
+
+ @symlink_test
+ @unittest.skipUnless(hasattr(os, 'chown'), "missing os.chown")
+ @unittest.skipUnless(hasattr(os, 'lchown'), "missing os.lchown")
+ @unittest.skipUnless(hasattr(os, 'geteuid'), "missing os.geteuid")
+ def test_chown_links_on_extractall(self):
+ for link_type in (tarfile.SYMTYPE, tarfile.LNKTYPE):
+ with ArchiveMaker() as arc:
+ arc.add("test.txt",
+ uid=1337, gid=1337, uname="", gname="", mode='-rwxr-xr-x')
+ arc.add("link",
+ type=link_type,
+ linkname='test.txt',
+ uid=1337, gid=1337, uname="", gname="", mode='-rwxr-xr-x')
+
+ with (
+ os_helper.temp_dir() as tmpdir,
+ arc.open() as tar,
+ unittest.mock.patch("os.chown") as mock_chown,
+ unittest.mock.patch("os.lchown") as mock_lchown,
+ unittest.mock.patch("os.geteuid") as mock_geteuid,
+ ):
+ # Set UID to 0 so chown() is attempted.
+ mock_geteuid.return_value = 0
+ tar.extractall(path=tmpdir, filter='data')
+ extract_link_path = os.path.join(tmpdir, "link")
+ extract_file_path = os.path.join(tmpdir, "test.txt")
+
+ if link_type == tarfile.SYMTYPE:
+ mock_chown.assert_called_once_with(extract_file_path, -1, -1)
+ mock_lchown.assert_called_once_with(extract_link_path, -1, -1)
+ else:
+ mock_chown.assert_has_calls([
+ unittest.mock.call(extract_file_path, -1, -1),
+ unittest.mock.call(extract_link_path, -1, -1)
+ ])
+ mock_lchown.assert_not_called()
+
+ def test_extract_filters_target(self):
+ # Test that when extract() falls back to extracting (rather than
+ # linking) a hardlink target, it filters the target.
+ with ArchiveMaker() as arc:
+ arc.add("target")
+ arc.add("link", hardlink_to="target")
+ def testing_filter(member, path):
+ if member.name == 'target':
+ # target: set read-only
+ return member.replace(mode=stat.S_IRUSR)
+ # link: don't overwrite the mode
+ return member.replace(mode=None)
+ tempdir = pathlib.Path(TEMPDIR) / 'extract'
+ with os_helper.temp_dir(tempdir), arc.open() as tar:
+ tar.extract("link", path=tempdir, filter=testing_filter)
+ path = tempdir / 'link'
+ if os_helper.can_chmod():
+ self.assertFalse(path.stat().st_mode & stat.S_IWUSR)
+
def test_link_fallback_normalizes(self):
# Make sure hardlink fallbacks work for non-normalized paths for all
# filters
new file mode 100644
@@ -0,0 +1,2 @@
+The :meth:`tarfile.TarFile.extract` method now applies the given filter when
+it extracts a link target from the archive as a fallback.