From b3d178a58e7bebaeab1e55d199a32c56829e40a4 Mon Sep 17 00:00:00 2001 From: Shrey Naithani Date: Sun, 29 Mar 2026 13:31:30 +0530 Subject: [PATCH 1/8] Update shutil.py --- Lib/shutil.py | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/Lib/shutil.py b/Lib/shutil.py index 44ccdbb503d4fb..798c33deee65b8 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -1309,7 +1309,7 @@ def _ensure_directory(path): if not os.path.isdir(dirname): os.makedirs(dirname) -def _unpack_zipfile(filename, extract_dir): +def _unpack_zipfile(filename, extract_dir, **kwargs): """Unpack zip `filename` to `extract_dir` """ import zipfile # late import for breaking circular dependency @@ -1317,27 +1317,9 @@ def _unpack_zipfile(filename, extract_dir): if not zipfile.is_zipfile(filename): raise ReadError("%s is not a zip file" % filename) - zip = zipfile.ZipFile(filename) - try: - for info in zip.infolist(): - name = info.filename - - # don't extract absolute paths or ones with .. in them - if name.startswith('/') or '..' in name: - continue + with zipfile.ZipFile(filename) as zf: + zf.extractall(extract_dir) - targetpath = os.path.join(extract_dir, *name.split('/')) - if not targetpath: - continue - - _ensure_directory(targetpath) - if not name.endswith('/'): - # file - with zip.open(name, 'r') as source, \ - open(targetpath, 'wb') as target: - copyfileobj(source, target) - finally: - zip.close() def _unpack_tarfile(filename, extract_dir, *, filter=None): """Unpack tar/tar.gz/tar.bz2/tar.xz/tar.zst `filename` to `extract_dir` @@ -1668,3 +1650,4 @@ def __getattr__(name): ) return RuntimeError raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + From 494d2b19cf22b02c88b95e796719c7bb90220535 Mon Sep 17 00:00:00 2001 From: Shrey Naithani Date: Sun, 29 Mar 2026 13:33:12 +0530 Subject: [PATCH 2/8] Implement Windows ZIP traversal test in test_shutil Add a test case for ZIP file traversal on Windows. --- Lib/test/test_shutil.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index a4bd113bc7f1fc..edd5039397427e 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -3523,3 +3523,38 @@ def test_module_all_attribute(self): if __name__ == '__main__': unittest.main() + +class TestShutilZipTraversal(unittest.TestCase): + def setUp(self): + self.tmp_dir = tempfile.mkdtemp() + self.extract_dir = os.path.join(self.tmp_dir, "extract") + os.mkdir(self.extract_dir) + + def tearDown(self): + shutil.rmtree(self.tmp_dir) + + @unittest.skipUnless(sys.platform == 'win32', 'Windows-specific traversal test') + @support.requires_zlib() + def test_unpack_zipfile_traversal_windows_drive(self): + # Create a ZIP file with a drive-prefixed path + zip_path = os.path.join(self.tmp_dir, "test.zip") + with zipfile.ZipFile(zip_path, 'w') as zf: + # zipfile.extractall() should sanitize this to 'D/traversal.txt' + # relative to extract_dir. + zf.writestr("D:/traversal.txt", "found you") + + # Prior to the fix, this might have attempted to write to D:/traversal.txt + # With the fix (using extractall()), it's safely joined. + shutil.unpack_archive(zip_path, self.extract_dir) + + # Check that it didn't go to D:/ + self.assertFalse(os.path.exists("D:/traversal.txt")) + + # Check where it actually went + found = False + for root, dirs, files in os.walk(self.extract_dir): + if "traversal.txt" in files: + found = True + break + self.assertTrue(found, "Extracted file not found within extract_dir") + From d18590531c7f2d7e43333c7b3cb3185d70b4f537 Mon Sep 17 00:00:00 2001 From: Shrey Naithani Date: Sun, 29 Mar 2026 13:36:18 +0530 Subject: [PATCH 3/8] Clarify comments in test_unpack_zipfile_traversal_windows_drive Updated comments for clarity and removed redundant lines. --- Lib/test/test_shutil.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index edd5039397427e..ab454e91089993 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -3536,25 +3536,15 @@ def tearDown(self): @unittest.skipUnless(sys.platform == 'win32', 'Windows-specific traversal test') @support.requires_zlib() def test_unpack_zipfile_traversal_windows_drive(self): - # Create a ZIP file with a drive-prefixed path + # Create a ZIP file with a drive prefixed path zip_path = os.path.join(self.tmp_dir, "test.zip") with zipfile.ZipFile(zip_path, 'w') as zf: - # zipfile.extractall() should sanitize this to 'D/traversal.txt' - # relative to extract_dir. zf.writestr("D:/traversal.txt", "found you") - - # Prior to the fix, this might have attempted to write to D:/traversal.txt - # With the fix (using extractall()), it's safely joined. shutil.unpack_archive(zip_path, self.extract_dir) - - # Check that it didn't go to D:/ self.assertFalse(os.path.exists("D:/traversal.txt")) - - # Check where it actually went found = False for root, dirs, files in os.walk(self.extract_dir): if "traversal.txt" in files: found = True break self.assertTrue(found, "Extracted file not found within extract_dir") - From 958ec29b135fec8115fec08f708ff0b2a29f1730 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 08:14:10 +0000 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20blu?= =?UTF-8?q?rb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../next/Security/2026-03-29-08-14-09.gh-issue-146581.81t9a4.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Security/2026-03-29-08-14-09.gh-issue-146581.81t9a4.rst diff --git a/Misc/NEWS.d/next/Security/2026-03-29-08-14-09.gh-issue-146581.81t9a4.rst b/Misc/NEWS.d/next/Security/2026-03-29-08-14-09.gh-issue-146581.81t9a4.rst new file mode 100644 index 00000000000000..e31a8b943f1620 --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-03-29-08-14-09.gh-issue-146581.81t9a4.rst @@ -0,0 +1 @@ +Fix a directory traversal vulnerability in shutil.unpack_archive for ZIP files on Windows by refactoring _unpack_zipfile to use zipfile.ZipFile.extractall. This leverages the built in, hardened path sanitization in the zipfile module to safely handle drive prefixed paths. Patch by Shrey Naithani. From b0f0dc8ebcbe2aa72295eb8f36b89177f30c7cba Mon Sep 17 00:00:00 2001 From: Shrey Naithani Date: Sun, 29 Mar 2026 13:46:39 +0530 Subject: [PATCH 5/8] Remove unnecessary blank lines in test_shutil.py --- Lib/test/test_shutil.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index ab454e91089993..40ef6258913648 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -3519,20 +3519,15 @@ def test_module_all_attribute(self): self.assertEqual(set(shutil.__all__), set(target_api)) with self.assertWarns(DeprecationWarning): from shutil import ExecError # noqa: F401 - - if __name__ == '__main__': unittest.main() - class TestShutilZipTraversal(unittest.TestCase): def setUp(self): self.tmp_dir = tempfile.mkdtemp() self.extract_dir = os.path.join(self.tmp_dir, "extract") os.mkdir(self.extract_dir) - def tearDown(self): shutil.rmtree(self.tmp_dir) - @unittest.skipUnless(sys.platform == 'win32', 'Windows-specific traversal test') @support.requires_zlib() def test_unpack_zipfile_traversal_windows_drive(self): From 8ce8bc2343b1813ebe0f95b9897f4f19d6c09f75 Mon Sep 17 00:00:00 2001 From: Shrey Naithani Date: Sun, 29 Mar 2026 13:47:34 +0530 Subject: [PATCH 6/8] Clean up whitespace in _unpack_zipfile function Removed unnecessary blank lines in _unpack_zipfile function. --- Lib/shutil.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/Lib/shutil.py b/Lib/shutil.py index 798c33deee65b8..8897166506bdf4 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -1313,14 +1313,11 @@ def _unpack_zipfile(filename, extract_dir, **kwargs): """Unpack zip `filename` to `extract_dir` """ import zipfile # late import for breaking circular dependency - if not zipfile.is_zipfile(filename): raise ReadError("%s is not a zip file" % filename) - with zipfile.ZipFile(filename) as zf: zf.extractall(extract_dir) - def _unpack_tarfile(filename, extract_dir, *, filter=None): """Unpack tar/tar.gz/tar.bz2/tar.xz/tar.zst `filename` to `extract_dir` """ From cfb3d0fdb0f8367667715978cf4049314e152cc6 Mon Sep 17 00:00:00 2001 From: Shrey Naithani Date: Sun, 29 Mar 2026 13:49:34 +0530 Subject: [PATCH 7/8] Update shutil.py --- Lib/shutil.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/shutil.py b/Lib/shutil.py index 8897166506bdf4..e9a7b621395499 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -1647,4 +1647,3 @@ def __getattr__(name): ) return RuntimeError raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - From 678fc3d5a269f5e31ca0b10c9487304832fb1a10 Mon Sep 17 00:00:00 2001 From: Shrey Naithani Date: Sun, 29 Mar 2026 14:05:27 +0530 Subject: [PATCH 8/8] Update shutil.py --- Lib/shutil.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/shutil.py b/Lib/shutil.py index e9a7b621395499..42661dec3f60c5 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -1309,7 +1309,7 @@ def _ensure_directory(path): if not os.path.isdir(dirname): os.makedirs(dirname) -def _unpack_zipfile(filename, extract_dir, **kwargs): +def _unpack_zipfile(filename, extract_dir): """Unpack zip `filename` to `extract_dir` """ import zipfile # late import for breaking circular dependency