Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python311 for openSUSE:Factory checked in at 2026-09-24 22:55:18 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python311 (Old) and /work/SRC/openSUSE:Factory/.python311.new.383539 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python311" Thu Sep 24 22:55:18 2026 rev:74 rq:1379773 version:3.11.16 Changes: -------- --- /work/SRC/openSUSE:Factory/python311/python311.changes 2026-09-11 19:00:07.746612212 +0200 +++ /work/SRC/openSUSE:Factory/.python311.new.383539/python311.changes 2026-09-24 22:55:20.894915530 +0200 @@ -1,0 +2,27 @@ +Thu Sep 17 23:49:43 UTC 2026 - Matej Cepl <[email protected]> + +- CVE-2026-15310: bound zipfile decompression for + bzip2/LZMA (bsc#1277111, gh#python/cpython#156002) + CVE-2026-15310-bound-zipfile-decompression.patch + +------------------------------------------------------------------- +Tue Sep 15 10:29:32 UTC 2026 - Matej Cepl <[email protected]> + +- CVE-2026-19672: in tarfile, handle a member that leaves the + destination and comes back (bsc#1276227, gh#python/cpython#156000) + CVE-2026-19672-tarfile-outside-dirs.patch + +------------------------------------------------------------------- +Fri Sep 11 19:59:21 UTC 2026 - Matej Cepl <[email protected]> + +CVE-2026-17084: Don't consider Unicode codepoint attributes + outside RFC 3454 (bsc#1276226) + CVE-2026-17084-stringprep-rfc3454.patch +------------------------------------------------------------------- +Thu Sep 10 18:29:11 UTC 2026 - Matej Cepl <[email protected]> + +- CVE-2026-15806: Scope HTTPPasswordMgr credentials by URL scheme + (bsc#1276223) + * Add CVE-2026-15806-HTTPPasswordMgr-scheme.patch + +------------------------------------------------------------------- New: ---- CVE-2026-15310-bound-zipfile-decompression.patch CVE-2026-15806-HTTPPasswordMgr-scheme.patch CVE-2026-17084-stringprep-rfc3454.patch CVE-2026-19672-tarfile-outside-dirs.patch ----------(New B)---------- New: bzip2/LZMA (bsc#1277111, gh#python/cpython#156002) CVE-2026-15310-bound-zipfile-decompression.patch New: (bsc#1276223) * Add CVE-2026-15806-HTTPPasswordMgr-scheme.patch New: outside RFC 3454 (bsc#1276226) CVE-2026-17084-stringprep-rfc3454.patch ------------------------------------------------------------------- New: destination and comes back (bsc#1276227, gh#python/cpython#156000) CVE-2026-19672-tarfile-outside-dirs.patch ----------(New E)---------- ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python311.spec ++++++ --- /var/tmp/diff_new_pack.Xp0R1x/_old 2026-09-24 22:55:22.066964510 +0200 +++ /var/tmp/diff_new_pack.Xp0R1x/_new 2026-09-24 22:55:22.068964594 +0200 @@ -35,13 +35,11 @@ %bcond_with base %bcond_without general %endif - %if 0%{?do_profiling} && !0%{?want_reproducible_builds} %bcond_without profileopt %else %bcond_with profileopt %endif - # Only for Tumbleweed # https://en.opensuse.org/openSUSE:Python:Externally_managed %if 0%{?suse_version} > 1600 @@ -49,14 +47,12 @@ %else %bcond_with externally_managed %endif - %define python_pkg_name python311 %if "%{python_pkg_name}" == "%{primary_python}" %define primary_interpreter 1 %else %define primary_interpreter 0 %endif - # Setting up variables %define _version %(c=%{version}; echo ${c/[a-z]*/}) %define tar_suffix %(c=%{_version}; echo ${c#%{_version}}) @@ -210,6 +206,18 @@ # PATCH-FIX-UPSTREAM bsc1263083-http-cookies-atob-utf8.patch bsc#1263083 [email protected] # Use decodeURIComponent() for UTF-8 support in js_output() Patch58: bsc1263083-http-cookies-atob-utf8.patch +# PATCH-FIX-UPSTREAM CVE-2026-15806-HTTPPasswordMgr-scheme.patch bsc#1276223 [email protected] +# Scope HTTPPasswordMgr credentials by URL scheme +Patch59: CVE-2026-15806-HTTPPasswordMgr-scheme.patch +# PATCH-FIX-UPSTREAM CVE-2026-17084-stringprep-rfc3454.patch bsc#1276226 [email protected] +# Don't consider Unicode codepoint attributes outside RFC 3454 +Patch60: CVE-2026-17084-stringprep-rfc3454.patch +# PATCH-FIX-UPSTREAM CVE-2026-19672-tarfile-outside-dirs.patch bsc#1276227 [email protected] +# in tarfile, handle a member that leaves the destination and comes back +Patch61: CVE-2026-19672-tarfile-outside-dirs.patch +# PATCH-FIX-UPSTREAM CVE-2026-15310-bound-zipfile-decompression.patch bsc#1277111 [email protected] +# Bound zipfile decompression for bzip2/LZMA (gh#python/cpython!156003) +Patch62: CVE-2026-15310-bound-zipfile-decompression.patch ### END OF PATCHES BuildRequires: autoconf-archive BuildRequires: automake @@ -227,6 +235,7 @@ BuildRequires: pkgconfig(uuid) BuildRequires: pkgconfig(zlib) #!BuildIgnore: gdk-pixbuf-loader-rsvg +%{?suse_build_hwcaps_libs} %if 0%{?suse_version} >= 1550 && %{without base} # Skip for the base flavor: rpm-build-python requires python3-base, which # creates an unresolvable dependency loop when building python3xx-base itself. @@ -256,13 +265,12 @@ BuildRequires: pkgconfig(tk) BuildRequires: pkgconfig(x11) Requires: %{python_pkg_name}-base = %{version} -Provides: %{python_pkg_name}-readline -Provides: %{python_pkg_name}-sqlite3 Recommends: %{python_pkg_name}-curses Recommends: %{python_pkg_name}-dbm Recommends: %{python_pkg_name}-pip +Provides: %{python_pkg_name}-readline +Provides: %{python_pkg_name}-sqlite3 %endif -%{?suse_build_hwcaps_libs} %description Python 3 is modern interpreted, object-oriented programming language, ++++++ CVE-2026-15310-bound-zipfile-decompression.patch ++++++ >From 7a2b7388233ea0c647168c5792dc31f3051fe177 Mon Sep 17 00:00:00 2001 From: Petr Viktorin <[email protected]> Date: Mon, 31 Aug 2026 21:04:04 +0200 Subject: [PATCH 1/3] [3.15] gh-156002: Bound zipfile decompression for bzip2/LZMA (GH-156003) (GH-156362) Patch by @tonghuaroot. zipfile.ZipExtFile._read1() bounds the output of each decompress() call for DEFLATE members by passing a max_length to zlib, but for bzip2, LZMA, members it called decompress() with no bound. A whole compressed chunk was therefore expanded into a single allocation before the data[:self._left] clip ran, so a consumer that deliberately reads in small chunks to limit memory (for example zf.open(name).read(8192)) was silently unprotected for non-DEFLATE members. A small, spec-conformant archive member declaring a large uncompressed size could drive multi-GB peak memory. _read1() now passes a per-call bound to the non-DEFLATE decompress() (mirroring the DEFLATE branch) and drains the decompressor's internal buffer across calls by checking needs_input before reading more compressed input. zipfile's LZMADecompressor wrapper forwards max_length and exposes needs_input so the bound also holds for LZMA members. (cherry picked from commit f897dbf2f36a5935700b7c2d94d4681d2136b7d4) (cherry picked from commit 1b424c0178a01e155fd0267dc28a8fc1159b33a8) Co-authored-by: Petr Viktorin <[email protected]> Co-authored-by: tonghuaroot <[email protected]> --- Lib/test/test_zipfile.py | 104 ++++++++++ Lib/zipfile.py | 35 ++- Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst | 5 Misc/NEWS.d/next/Security/2026-08-18-13-54-05.gh-issue-156002.CcWXPP.rst | 4 4 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Security/2026-08-18-13-54-05.gh-issue-156002.CcWXPP.rst Index: Python-3.11.16/Lib/test/test_zipfile.py =================================================================== --- Python-3.11.16.orig/Lib/test/test_zipfile.py 2026-09-18 02:13:30.182873716 +0200 +++ Python-3.11.16/Lib/test/test_zipfile.py 2026-09-18 02:13:32.565920004 +0200 @@ -2590,6 +2590,110 @@ unlink(TESTFN2) +class AbstractBoundedDecompressTests: + # ZipExtFile._read1() bounds the output of each decompress() call so that a + # small member declaring a large uncompressed size cannot expand into one + # unbounded read. + def test_read1_output_is_bounded(self): + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=self.compression) as zf: + zf.writestr("big", b"\0" * (4 * 1024 * 1024)) + with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf: + with zf.open("big") as f: + self.assertLessEqual(len(f._read1(100)), f.MIN_READ_SIZE) + + +class StoredBoundedDecompressTests(AbstractBoundedDecompressTests, + unittest.TestCase): + compression = zipfile.ZIP_STORED + + +@requires_zlib() +class DeflateBoundedDecompressTests(AbstractBoundedDecompressTests, + unittest.TestCase): + compression = zipfile.ZIP_DEFLATED + + +@requires_bz2() +class Bzip2BoundedDecompressTests(AbstractBoundedDecompressTests, + unittest.TestCase): + compression = zipfile.ZIP_BZIP2 + + +@requires_lzma() +class LzmaBoundedDecompressTests(AbstractBoundedDecompressTests, + unittest.TestCase): + compression = zipfile.ZIP_LZMA + + + +class MonkeypatchedDecompressorTests(unittest.TestCase): + # Some third-party projects monkey-patch _get_decompressor() to add + # additional compression schemes. This can break at any time as the + # internal compressor objects change. + # To protect users, we try to keep this case working. + # See also: GH-156002 and GH-113767. + COMPRESSION = 99 + + class Compressor: + """Compressor with only the original BZ2Compressor API""" + def compress(self, data): + return data.swapcase() + + def flush(self): + return b'' + + class Decompressor: + """Decompressor with only the 3.3+ BZ2Decompressor API""" + eof = False + + def decompress(self, data): + return data.swapcase() + + def setUp(self): + orig_check_compression = zipfile._check_compression + orig_get_compressor = zipfile._get_compressor + orig_get_decompressor = zipfile._get_decompressor + + def check_compression(compression): + if compression != self.COMPRESSION: + orig_check_compression(compression) + + def get_compressor(compress_type, compresslevel=None): + if compress_type == self.COMPRESSION: + return self.Compressor() + return orig_get_compressor(compress_type, compresslevel) + + def get_decompressor(compress_type): + if compress_type == self.COMPRESSION: + return self.Decompressor() + return orig_get_decompressor(compress_type) + + self.enterContext(mock.patch.object( + zipfile, '_check_compression', check_compression)) + self.enterContext(mock.patch.object( + zipfile, '_get_compressor', get_compressor)) + self.enterContext(mock.patch.object( + zipfile, '_get_decompressor', get_decompressor)) + + def test_roundtrip_monkeypatched_decompressor(self): + data = bytes(range(256)) * 8 + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=self.COMPRESSION) as zf: + zf.writestr("member", data) + self.assertIn(data.swapcase(), buf.getvalue()) + with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf: + self.assertEqual(zf.read("member"), data) + with zf.open("member") as f: + self.assertEqual(f.read(100), data[:100]) + self.assertEqual(f.read1(100), data[100:200]) + f.seek(-100, os.SEEK_END) + self.assertEqual(f.read(), data[-100:]) + # Rewinding past the read buffer re-creates the decompressor. + f.seek(0) + self.assertEqual(f.read(), data) + + class AbstractBadCrcTests: def test_testzip_with_bad_crc(self): """Tests that files with bad CRCs return their name from testzip.""" Index: Python-3.11.16/Lib/zipfile.py =================================================================== --- Python-3.11.16.orig/Lib/zipfile.py 2026-09-18 02:13:30.518309790 +0200 +++ Python-3.11.16/Lib/zipfile.py 2026-09-18 02:13:32.567802775 +0200 @@ -698,7 +698,16 @@ self._unconsumed = b'' self.eof = False - def decompress(self, data): + @property + def needs_input(self): + # While the LZMA properties header is still being buffered, more input + # is required; afterwards defer to the wrapped decompressor so a bounded + # decompress() call can be drained across reads. + if self._decomp is None: + return True + return self._decomp.needs_input + + def decompress(self, data, max_length=-1): if self._decomp is None: self._unconsumed += data if len(self._unconsumed) <= 4: @@ -714,7 +723,7 @@ data = self._unconsumed[4 + psize:] del self._unconsumed - result = self._decomp.decompress(data) + result = self._decomp.decompress(data, max_length) self.eof = self._decomp.eof return result @@ -1074,8 +1083,15 @@ data = self._decompressor.unconsumed_tail if n > len(data): data += self._read2(n - len(data)) - else: + elif self._compress_type == ZIP_STORED: data = self._read2(n) + else: + # bzip2/lzma/zstd: a bounded decompress() call may leave input + # buffered inside the decompressor; drain that before reading more. + if getattr(self._decompressor, "needs_input", True): + data = self._read2(n) + else: + data = b'' if self._compress_type == ZIP_STORED: self._eof = self._compress_left <= 0 @@ -1088,8 +1104,17 @@ if self._eof: data += self._decompressor.flush() else: - data = self._decompressor.decompress(data) - self._eof = self._decompressor.eof or self._compress_left <= 0 + # Bound the output of a single decompress() call (mirroring the + # DEFLATE path above) so that a small compressed member cannot + # expand into one unbounded read. + try: + data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE)) + except TypeError: + # See MonkeypatchedDecompressorTests in test_zipfile.py + data = self._decompressor.decompress(data) + self._eof = (self._decompressor.eof or + self._compress_left <= 0 and + getattr(self._decompressor, "needs_input", True)) data = data[:self._left] self._left -= len(data) Index: Python-3.11.16/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst =================================================================== --- /dev/null 1970-01-01 00:00:00.000000000 +0000 +++ Python-3.11.16/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst 2026-09-18 02:13:32.568427063 +0200 @@ -0,0 +1,5 @@ +:mod:`zipfile` again reads members through a third-party decompressor +installed by monkey-patching the private ``_get_decompressor()`` to return an +object that only implements old BZ2Decompressor API from Python 3.3. +Note that decompressors without ``needs_input`` and two-argument +``decompress()`` are vulnerable to :cve:`2026-15310`. Index: Python-3.11.16/Misc/NEWS.d/next/Security/2026-08-18-13-54-05.gh-issue-156002.CcWXPP.rst =================================================================== --- /dev/null 1970-01-01 00:00:00.000000000 +0000 +++ Python-3.11.16/Misc/NEWS.d/next/Security/2026-08-18-13-54-05.gh-issue-156002.CcWXPP.rst 2026-09-18 02:13:32.568144339 +0200 @@ -0,0 +1,4 @@ +Bound the amount of data :mod:`zipfile` decompresses per read for members +compressed with bzip2 or LZMA, matching the existing limit for +deflate. A small archive member could previously expand into an unbounded +allocation even when read in small chunks. ++++++ CVE-2026-15806-HTTPPasswordMgr-scheme.patch ++++++ >From 95e57c3cfe9b6f9a9e70d067afeeff16aaa503b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz?= <[email protected]> Date: Mon, 17 Aug 2026 21:39:16 +0200 Subject: [PATCH 1/3] gh-155694: Scope HTTPPasswordMgr credentials by URL scheme (GH-155696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Credentials stored for an https:// URI were also matched against the corresponding http:// URI, since `reduce_uri()` discards the scheme. `HTTPPasswordMgr` and `HTTPPasswordMgrWithPriorAuth` now compare the scheme too; URIs registered without a scheme still match any scheme. (cherry picked from commit a7bb524fef61f77ede01f660ffbd591e1d5837ce) Co-authored-by: Ćukasz <[email protected]> --- Doc/library/urllib.request.rst | 10 +++- Lib/test/test_urllib2.py | 56 +++++++++++++++++++ Lib/urllib/request.py | 25 +++++++-- ...-07-31-16-20-17.gh-issue-155694.SsxlKG.rst | 4 ++ 4 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst Index: Python-3.11.16/Doc/library/urllib.request.rst =================================================================== --- Python-3.11.16.orig/Doc/library/urllib.request.rst 2026-08-13 01:03:19.000000000 +0200 +++ Python-3.11.16/Doc/library/urllib.request.rst 2026-09-10 20:27:18.832162876 +0200 @@ -939,8 +939,14 @@ *uri* can be either a single URI, or a sequence of URIs. *realm*, *user* and *passwd* must be strings. This causes ``(user, passwd)`` to be used as - authentication tokens when authentication for *realm* and a super-URI of any of - the given URIs is given. + authentication tokens when authentication for *realm* and a super-URI of any + of the given URIs is given. If a URI includes a scheme, its credentials only + match authentication URIs with the same scheme or no scheme. A URI without a + scheme matches authentication URIs with any scheme. + + .. versionchanged:: next + Authentication credentials for URIs with a scheme are now scoped by + that scheme. .. method:: HTTPPasswordMgr.find_user_password(realm, authuri) Index: Python-3.11.16/Lib/test/test_urllib2.py =================================================================== --- Python-3.11.16.orig/Lib/test/test_urllib2.py 2026-09-10 20:26:40.010207640 +0200 +++ Python-3.11.16/Lib/test/test_urllib2.py 2026-09-10 20:27:18.832540748 +0200 @@ -271,6 +271,50 @@ self.assertEqual(find_user_pass("i", "http://j.example.com:80"), (None, None)) + def test_password_manager_scheme(self): + mgr = urllib.request.HTTPPasswordMgr() + mgr.add_password( + "realm", "https://example.com/", "user", "password") + + self.assertEqual( + mgr.find_user_password("realm", "https://example.com/"), + ("user", "password")) + self.assertEqual( + mgr.find_user_password("realm", "http://example.com/"), + (None, None)) + # Support an authority without a scheme. + self.assertEqual( + mgr.find_user_password("realm", "example.com"), + ("user", "password")) + # An authority without a scheme continues to match any scheme. + mgr.add_password( + "realm", "schemeless.example.com", "user", "password") + for scheme in "http", "https": + with self.subTest(scheme=scheme): + self.assertEqual( + mgr.find_user_password( + "realm", f"{scheme}://schemeless.example.com/"), + ("user", "password")) + + # A network-path reference also has no scheme. + mgr.add_password( + "realm", "//network-path.example.com/", "user", "password") + self.assertEqual( + mgr.find_user_password( + "realm", "https://network-path.example.com/"), + ("user", "password")) + + def test_password_manager_reduced_uri(self): + mgr = urllib.request.HTTPPasswordMgr() + + self.assertEqual( + mgr.reduce_uri("http://example.com/path"), + ("example.com:80", "/path")) + self.assertTrue( + mgr.is_suburi( + ("example.com", "/path"), + ("example.com", "/path/subpath"))) + class MockOpener: addheaders = [] @@ -1714,6 +1758,18 @@ # expect request to be sent with auth header self.assertTrue(http_handler.has_auth_header) + def test_basic_prior_auth_different_scheme(self): + pwd_manager = HTTPPasswordMgrWithPriorAuth() + auth_handler = HTTPBasicAuthHandler(pwd_manager) + auth_handler.add_password( + None, "https://example.com/", "user", "password", + is_authenticated=True) + + request = Request("http://example.com/") + auth_handler.http_request(request) + + self.assertFalse(request.has_header("Authorization")) + def test_basic_prior_auth_send_after_first_success(self): # Auto send auth header after authentication is successful once Index: Python-3.11.16/Lib/urllib/request.py =================================================================== --- Python-3.11.16.orig/Lib/urllib/request.py 2026-09-10 20:26:40.450781742 +0200 +++ Python-3.11.16/Lib/urllib/request.py 2026-09-10 20:27:18.832947575 +0200 @@ -844,16 +844,17 @@ self.passwd[realm] = {} for default_port in True, False: reduced_uri = tuple( - self.reduce_uri(u, default_port) for u in uri) + self._reduce_uri_with_scheme(u, default_port) for u in uri) self.passwd[realm][reduced_uri] = (user, passwd) def find_user_password(self, realm, authuri): domains = self.passwd.get(realm, {}) for default_port in True, False: - reduced_authuri = self.reduce_uri(authuri, default_port) + reduced_authuri = self._reduce_uri_with_scheme( + authuri, default_port) for uris, authinfo in domains.items(): for uri in uris: - if self.is_suburi(uri, reduced_authuri): + if self._is_suburi_with_scheme(uri, reduced_authuri): return authinfo return None, None @@ -880,6 +881,17 @@ authority = "%s:%d" % (host, dport) return authority, path + def _reduce_uri_with_scheme(self, uri, default_port=True): + parts = urlsplit(uri) + scheme = parts[0] if parts[1] else None + return (scheme or None, *self.reduce_uri(uri, default_port)) + + def _is_suburi_with_scheme(self, base, test): + if (base[0] is not None and test[0] is not None and + base[0] != test[0]): + return False + return self.is_suburi(base[1:], test[1:]) + def is_suburi(self, base, test): """Check if test is below base in a URI tree @@ -925,14 +937,15 @@ for default_port in True, False: for u in uri: - reduced_uri = self.reduce_uri(u, default_port) + reduced_uri = self._reduce_uri_with_scheme(u, default_port) self.authenticated[reduced_uri] = is_authenticated def is_authenticated(self, authuri): for default_port in True, False: - reduced_authuri = self.reduce_uri(authuri, default_port) + reduced_authuri = self._reduce_uri_with_scheme( + authuri, default_port) for uri in self.authenticated: - if self.is_suburi(uri, reduced_authuri): + if self._is_suburi_with_scheme(uri, reduced_authuri): return self.authenticated[uri] Index: Python-3.11.16/Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst =================================================================== --- /dev/null 1970-01-01 00:00:00.000000000 +0000 +++ Python-3.11.16/Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst 2026-09-10 20:27:18.833718347 +0200 @@ -0,0 +1,4 @@ +Fix `CVE-2026-15806 <https://www.cve.org/CVERecord?id=CVE-2026-15806>`_ by scoping :class:`~urllib.request.HTTPPasswordMgr` +credentials to the URL scheme, preventing credentials stored for an HTTPS +URL from being used for a matching HTTP URL, while URIs without a scheme +continue to match any scheme. ++++++ CVE-2026-17084-stringprep-rfc3454.patch ++++++ ++++ 959 lines (skipped) ++++++ CVE-2026-19672-tarfile-outside-dirs.patch ++++++ >From 3ee68fa9516b9780d62cc17f68b0f359551c5ac1 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych <[email protected]> Date: Wed, 19 Aug 2026 09:52:01 +0100 Subject: [PATCH] gh-155999: `tarfile`: handle a member that leaves the destination but comes back (GH-156000) (cherry picked from commit 97688346ada2df3e5b9c279348862c3d64ab0823) Co-authored-by: Stan Ulbrych <[email protected]> --- Doc/library/tarfile.rst | 8 +++++ Lib/tarfile.py | 7 +++++ Lib/test/test_tarfile.py | 14 ++++++++++ Misc/NEWS.d/next/Security/2026-08-13-13-08-11.gh-issue-155999.Xt4rWq.rst | 5 +++ 4 files changed, 34 insertions(+) create mode 100644 Misc/NEWS.d/next/Security/2026-08-13-13-08-11.gh-issue-155999.Xt4rWq.rst Index: Python-3.11.16/Doc/library/tarfile.rst =================================================================== --- Python-3.11.16.orig/Doc/library/tarfile.rst 2026-08-13 01:03:19.000000000 +0200 +++ Python-3.11.16/Doc/library/tarfile.rst 2026-09-15 15:53:34.389577345 +0200 @@ -1033,6 +1033,10 @@ paths (in case the name is absolute even after stripping slashes, e.g. ``C:/foo`` on Windows). This raises :class:`~tarfile.AbsolutePathError`. + - Normalize filenames (:attr:`TarInfo.name`) that contain ``..`` components + using :func:`os.path.normpath`. + Note that this removes internal ``..`` components, which may change the + meaning of the name if it traverses symbolic links. - :ref:`Refuse <tarfile-extraction-refuse>` to extract files whose absolute path (after following symlinks) would end up outside the destination. This raises :class:`~tarfile.OutsideDestinationError`. @@ -1041,6 +1045,10 @@ Return the modified ``TarInfo`` member. + .. versionchanged:: next + + Filenames containing ``..`` components are now normalized. + .. function:: data_filter(member, path) Implements the ``'data'`` filter. Index: Python-3.11.16/Lib/tarfile.py =================================================================== --- Python-3.11.16.orig/Lib/tarfile.py 2026-09-15 15:53:29.552151439 +0200 +++ Python-3.11.16/Lib/tarfile.py 2026-09-15 15:53:34.389860590 +0200 @@ -777,6 +777,13 @@ # For example, 'C:/foo' on Windows. raise AbsolutePathError(member) # Ensure we stay in the destination + if '..' in name.replace(os.sep, '/').split('/'): + # Directories are created from the name as given, so a name that + # leaves the destination part-way through would create them + # outside it even if the resolved path stays inside. + normalized = os.path.normpath(name) + if normalized != name: + name = new_attrs['name'] = normalized target_path = os.path.realpath(os.path.join(dest_path, name), strict=os.path.ALLOW_MISSING) if os.path.commonpath([target_path, dest_path]) != dest_path: Index: Python-3.11.16/Lib/test/test_tarfile.py =================================================================== --- Python-3.11.16.orig/Lib/test/test_tarfile.py 2026-09-15 15:53:31.262667996 +0200 +++ Python-3.11.16/Lib/test/test_tarfile.py 2026-09-15 15:53:34.390386453 +0200 @@ -3615,6 +3615,20 @@ tarfile.AbsolutePathError, """['"].*escaped.evil['"] has an absolute path""") + def test_parent_dir_out_and_back(self): + # Test a member that leaves the destination and comes back. + # The containment check looks at the resolved path, which stays + # inside, but the intermediate directories are created from the + # name as given, which does not. + with ArchiveMaker() as arc: + arc.add(f'../escaped.evil/../{self.destdir.name}/sub/file', + content='content') + + for filter in 'tar', 'data': + with self.subTest(filter): + with self.check_context(arc.open(), filter): + self.expect_file('sub/file', content='content') + @symlink_test def test_parent_symlink(self): # Test interplaying symlinks Index: Python-3.11.16/Misc/NEWS.d/next/Security/2026-08-13-13-08-11.gh-issue-155999.Xt4rWq.rst =================================================================== --- /dev/null 1970-01-01 00:00:00.000000000 +0000 +++ Python-3.11.16/Misc/NEWS.d/next/Security/2026-08-13-13-08-11.gh-issue-155999.Xt4rWq.rst 2026-09-15 15:53:34.390762022 +0200 @@ -0,0 +1,5 @@ +Fix the :mod:`tarfile` ``tar`` and ``data`` extraction filters creating +directories outside the destination for members whose name leaves the +destination and returns to it, such as ``../evil/../dest/sub/file``. The +containment check used the resolved path, but intermediate directories were +created from the name as given. ++++++ _scmsync.obsinfo ++++++ --- /var/tmp/diff_new_pack.Xp0R1x/_old 2026-09-24 22:55:22.217970821 +0200 +++ /var/tmp/diff_new_pack.Xp0R1x/_new 2026-09-24 22:55:22.220970946 +0200 @@ -1,6 +1,6 @@ -mtime: 1788802751 -commit: 095976ce39ed3b0443d0b4bba207d5564941f83019da2e8eef3782b3ff79f113 +mtime: 1789690797 +commit: 5b7c5d747e0961cb68cf4ec3b1d41113884019b2b05eabc835b500919d7302a0 url: https://src.opensuse.org/python-interpreters/python311 -revision: 095976ce39ed3b0443d0b4bba207d5564941f83019da2e8eef3782b3ff79f113 +revision: 5b7c5d747e0961cb68cf4ec3b1d41113884019b2b05eabc835b500919d7302a0 projectscmsync: https://src.opensuse.org/python-interpreters/_ObsPrj ++++++ build.specials.obscpio ++++++ ++++++ build.specials.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/.gitignore new/.gitignore --- old/.gitignore 1970-01-01 01:00:00.000000000 +0100 +++ new/.gitignore 2026-09-18 02:19:57.000000000 +0200 @@ -0,0 +1,7 @@ +_build.* +*.obscpio +*.osc +.osc +.pbuild +python311*-build/ +*.rej
