From 0d480f2766db5313cf311c4f7ec3fd6f9e09615f Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 11 Aug 2024 19:48:50 -0400 Subject: [PATCH 1/2] gh-122905: Sanitize names in zipfile.Path. (#122906) Ported from zipp 3.19.1; ref jaraco/zipp#119. (cherry picked from commit 9cd03263100ddb1657826cc4a71470786cab3932) --- Lib/test/test_zipfile.py | 17 ++ Lib/zipfile.py | 61 +++++++++- Misc/NEWS.d/next/Library/2024-08-11-14-08-04.gh-issue-122905.7tDsxA.rst | 1 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2024-08-11-14-08-04.gh-issue-122905.7tDsxA.rst --- a/Lib/test/test_zipfile.py +++ b/Lib/test/test_zipfile.py @@ -3660,6 +3660,23 @@ with zipfile.ZipFile(io.BytesIO(), "w") zipfile.Path(zf) zf.extractall(source_path.parent) + def test_malformed_paths(self): + """ + Path should handle malformed paths. + """ + data = io.BytesIO() + zf = zipfile.ZipFile(data, "w") + zf.writestr("/one-slash.txt", b"content") + zf.writestr("//two-slash.txt", b"content") + zf.writestr("../parent.txt", b"content") + zf.filename = '' + root = zipfile.Path(zf) + assert list(map(str, root.iterdir())) == [ + 'one-slash.txt', + 'two-slash.txt', + 'parent.txt', + ] + class EncodedMetadataTests(unittest.TestCase): file_names = ['\u4e00', '\u4e8c', '\u4e09'] # Han 'one', 'two', 'three' --- a/Lib/zipfile.py +++ b/Lib/zipfile.py @@ -9,6 +9,7 @@ import io import itertools import os import posixpath +import re import shutil import stat import struct @@ -2245,7 +2246,65 @@ def _difference(minuend, subtrahend): return itertools.filterfalse(set(subtrahend).__contains__, minuend) -class CompleteDirs(ZipFile): +class SanitizedNames: + """ + ZipFile mix-in to ensure names are sanitized. + """ + + def namelist(self): + return list(map(self._sanitize, super().namelist())) + + @staticmethod + def _sanitize(name): + r""" + Ensure a relative path with posix separators and no dot names. + Modeled after + https://github.com/python/cpython/blob/bcc1be39cb1d04ad9fc0bd1b9193d3972835a57c/Lib/zipfile/__init__.py#L1799-L1813 + but provides consistent cross-platform behavior. + >>> san = SanitizedNames._sanitize + >>> san('/foo/bar') + 'foo/bar' + >>> san('//foo.txt') + 'foo.txt' + >>> san('foo/.././bar.txt') + 'foo/bar.txt' + >>> san('foo../.bar.txt') + 'foo../.bar.txt' + >>> san('\\foo\\bar.txt') + 'foo/bar.txt' + >>> san('D:\\foo.txt') + 'D/foo.txt' + >>> san('\\\\server\\share\\file.txt') + 'server/share/file.txt' + >>> san('\\\\?\\GLOBALROOT\\Volume3') + '?/GLOBALROOT/Volume3' + >>> san('\\\\.\\PhysicalDrive1\\root') + 'PhysicalDrive1/root' + Retain any trailing slash. + >>> san('abc/') + 'abc/' + Raises a ValueError if the result is empty. + >>> san('../..') + Traceback (most recent call last): + ... + ValueError: Empty filename + """ + + def allowed(part): + return part and part not in {'..', '.'} + + # Remove the drive letter. + # Don't use ntpath.splitdrive, because that also strips UNC paths + bare = re.sub('^([A-Z]):', r'\1', name, flags=re.IGNORECASE) + clean = bare.replace('\\', '/') + parts = clean.split('/') + joined = '/'.join(filter(allowed, parts)) + if not joined: + raise ValueError("Empty filename") + return joined + '/' * name.endswith('/') + + +class CompleteDirs(SanitizedNames, ZipFile): """ A ZipFile subclass that ensures that implied directories are always included in the namelist. --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-08-11-14-08-04.gh-issue-122905.7tDsxA.rst @@ -0,0 +1 @@ +:class:`zipfile.Path` objects now sanitize names from the zipfile.