From be25887dfa3afa6467e869b58ffd5dde3d79a2a94d283494cd9544a261618351 Mon Sep 17 00:00:00 2001 From: Matej Cepl Date: Thu, 29 Aug 2024 12:04:00 +0000 Subject: [PATCH 1/2] - Add CVE-2024-8088-inf-loop-zipfile_Path.patch to prevent malformed payload to cause infinite loops in zipfile.Path (bsc#1229704, CVE-2024-8088). OBS-URL: https://build.opensuse.org/package/show/devel:languages:python:Factory/python310?expand=0&rev=142 --- CVE-2024-8088-inf-loop-zipfile_Path.patch | 113 ++++++++++++++++++++++ python310.changes | 7 ++ python310.spec | 4 + 3 files changed, 124 insertions(+) create mode 100644 CVE-2024-8088-inf-loop-zipfile_Path.patch diff --git a/CVE-2024-8088-inf-loop-zipfile_Path.patch b/CVE-2024-8088-inf-loop-zipfile_Path.patch new file mode 100644 index 0000000..5dc18c3 --- /dev/null +++ b/CVE-2024-8088-inf-loop-zipfile_Path.patch @@ -0,0 +1,113 @@ +--- + 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(-) + +--- a/Lib/test/test_zipfile.py ++++ b/Lib/test/test_zipfile.py +@@ -3280,6 +3280,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 StripExtraTests(unittest.TestCase): + # Note: all of the "z" characters are technically invalid, but up +--- 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 +@@ -2182,7 +2183,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. diff --git a/python310.changes b/python310.changes index 815fac7..1c4ee52 100644 --- a/python310.changes +++ b/python310.changes @@ -1,3 +1,10 @@ +------------------------------------------------------------------- +Wed Aug 28 16:54:34 UTC 2024 - Matej Cepl + +- Add CVE-2024-8088-inf-loop-zipfile_Path.patch to prevent + malformed payload to cause infinite loops in zipfile.Path + (bsc#1229704, CVE-2024-8088). + ------------------------------------------------------------------- Wed Aug 7 13:40:44 UTC 2024 - Matej Cepl diff --git a/python310.spec b/python310.spec index 45f3fda..6a2f231 100644 --- a/python310.spec +++ b/python310.spec @@ -209,6 +209,9 @@ Patch24: bso1227999-reproducible-builds.patch # PATCH-FIX-UPSTREAM CVE-2024-6923-email-hdr-inject.patch bsc#1228780 mcepl@suse.com # prevent email header injection, patch from gh#python/cpython!122608 Patch25: CVE-2024-6923-email-hdr-inject.patch +# PATCH-FIX-UPSTREAM CVE-2024-8088-inf-loop-zipfile_Path.patch bsc#1229704 mcepl@suse.com +# avoid denial of service in zipfile +Patch26: CVE-2024-8088-inf-loop-zipfile_Path.patch BuildRequires: autoconf-archive BuildRequires: automake BuildRequires: fdupes @@ -489,6 +492,7 @@ other applications. %patch -p1 -P 23 %patch -p1 -P 24 %patch -p1 -P 25 +%patch -p1 -P 26 # drop Autoconf version requirement sed -i 's/^AC_PREREQ/dnl AC_PREREQ/' configure.ac From eb0f4f61b01c83f769cb1918a4f979159d5e3271ba074e89cb2ac32dd195985c Mon Sep 17 00:00:00 2001 From: Matej Cepl Date: Thu, 29 Aug 2024 12:14:10 +0000 Subject: [PATCH 2/2] Update patch OBS-URL: https://build.opensuse.org/package/show/devel:languages:python:Factory/python310?expand=0&rev=143 --- CVE-2024-8088-inf-loop-zipfile_Path.patch | 179 ++++++++++++---------- 1 file changed, 101 insertions(+), 78 deletions(-) diff --git a/CVE-2024-8088-inf-loop-zipfile_Path.patch b/CVE-2024-8088-inf-loop-zipfile_Path.patch index 5dc18c3..5ef5630 100644 --- a/CVE-2024-8088-inf-loop-zipfile_Path.patch +++ b/CVE-2024-8088-inf-loop-zipfile_Path.patch @@ -1,31 +1,90 @@ --- - Lib/test/test_zipfile.py | 17 ++ - Lib/zipfile.py | 61 +++++++++- + Lib/test/test_zipfile.py | 75 ++++++++++ + Lib/zipfile.py | 10 + Misc/NEWS.d/next/Library/2024-08-11-14-08-04.gh-issue-122905.7tDsxA.rst | 1 - 3 files changed, 78 insertions(+), 1 deletion(-) + Misc/NEWS.d/next/Library/2024-08-26-13-45-20.gh-issue-123270.gXHvNJ.rst | 3 + 4 files changed, 87 insertions(+), 2 deletions(-) --- a/Lib/test/test_zipfile.py +++ b/Lib/test/test_zipfile.py -@@ -3280,6 +3280,23 @@ with zipfile.ZipFile(io.BytesIO(), "w") +@@ -3280,6 +3280,81 @@ with zipfile.ZipFile(io.BytesIO(), "w") zipfile.Path(zf) zf.extractall(source_path.parent) + def test_malformed_paths(self): + """ -+ Path should handle malformed paths. ++ Path should handle malformed paths gracefully. ++ ++ Paths with leading slashes are not visible. ++ ++ Paths with dots are treated like regular files. + """ + 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', -+ ] ++ assert list(map(str, root.iterdir())) == ['../'] ++ assert root.joinpath('..').joinpath('parent.txt').read_bytes() == b'content' ++ ++ def test_unsupported_names(self): ++ """ ++ Path segments with special characters are readable. ++ ++ On some platforms or file systems, characters like ++ ``:`` and ``?`` are not allowed, but they are valid ++ in the zip file. ++ """ ++ data = io.BytesIO() ++ zf = zipfile.ZipFile(data, "w") ++ zf.writestr("path?", b"content") ++ zf.writestr("V: NMS.flac", b"fLaC...") ++ zf.filename = '' ++ root = zipfile.Path(zf) ++ contents = root.iterdir() ++ assert next(contents).name == 'path?' ++ assert next(contents).name == 'V: NMS.flac' ++ assert root.joinpath('V: NMS.flac').read_bytes() == b"fLaC..." ++ ++ def test_backslash_not_separator(self): ++ """ ++ In a zip file, backslashes are not separators. ++ """ ++ data = io.BytesIO() ++ zf = zipfile.ZipFile(data, "w") ++ zf.writestr(DirtyZipInfo.for_name("foo\\bar", zf), b"content") ++ zf.filename = '' ++ root = zipfile.Path(zf) ++ (first,) = root.iterdir() ++ assert not first.is_dir() ++ assert first.name == 'foo\\bar' ++ ++ ++class DirtyZipInfo(zipfile.ZipInfo): ++ """ ++ Bypass name sanitization. ++ """ ++ ++ def __init__(self, filename, *args, **kwargs): ++ super().__init__(filename, *args, **kwargs) ++ self.filename = filename ++ ++ @classmethod ++ def for_name(cls, name, archive): ++ """ ++ Construct the same way that ZipFile.writestr does. ++ ++ TODO: extract this functionality and re-use ++ """ ++ self = cls(filename=name, date_time=time.localtime(time.time())[:6]) ++ self.compress_type = archive.compression ++ self.compress_level = archive.compresslevel ++ if self.filename.endswith('/'): # pragma: no cover ++ self.external_attr = 0o40775 << 16 # drwxrwxr-x ++ self.external_attr |= 0x10 # MS-DOS directory flag ++ else: ++ self.external_attr = 0o600 << 16 # ?rw------- ++ return self + class StripExtraTests(unittest.TestCase): @@ -40,74 +99,38 @@ import shutil import stat import struct -@@ -2182,7 +2183,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): +@@ -2151,7 +2152,7 @@ def _parents(path): + def _ancestry(path): """ - A ZipFile subclass that ensures that implied directories - are always included in the namelist. + Given a path with elements separated by +- posixpath.sep, generate all elements of that path ++ posixpath.sep, generate all elements of that path. + + >>> list(_ancestry('b/d')) + ['b/d', 'b'] +@@ -2163,9 +2164,14 @@ def _ancestry(path): + ['b'] + >>> list(_ancestry('')) + [] ++ ++ Multiple separators are treated like a single. ++ ++ >>> list(_ancestry('//b//d///f//')) ++ ['//b//d///f', '//b//d', '//b'] + """ + path = path.rstrip(posixpath.sep) +- while path and path != posixpath.sep: ++ while path.rstrip(posixpath.sep): + yield path + path, tail = posixpath.split(path) + --- /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. +--- /dev/null ++++ b/Misc/NEWS.d/next/Library/2024-08-26-13-45-20.gh-issue-123270.gXHvNJ.rst +@@ -0,0 +1,3 @@ ++Applied a more surgical fix for malformed payloads in :class:`zipfile.Path` ++causing infinite loops (gh-122905) without breaking contents using ++legitimate characters.