Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python310 for openSUSE:Factory checked in at 2026-10-01 16:46:15 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python310 (Old) and /work/SRC/openSUSE:Factory/.python310.new.1253 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python310" Thu Oct 1 16:46:15 2026 rev:83 rq:1381747 version:3.10.21 Changes: -------- --- /work/SRC/openSUSE:Factory/python310/python310.changes 2026-09-10 11:51:05.962856029 +0200 +++ /work/SRC/openSUSE:Factory/.python310.new.1253/python310.changes 2026-10-01 16:47:15.795589456 +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/Zstandard (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 20:18:06 UTC 2026 - Matej Cepl <[email protected]> + +- CVE-2026-17084: Don't consider Unicode codepoint attributes + outside RFC 3454 (bsc#1276226) + * Add 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/Zstandard (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) * Add 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: ------------------ ++++++ python310.spec ++++++ --- /var/tmp/diff_new_pack.frFgMc/_old 2026-10-01 16:47:17.133645536 +0200 +++ /var/tmp/diff_new_pack.frFgMc/_new 2026-10-01 16:47:17.135645620 +0200 @@ -233,6 +233,18 @@ # PATCH-FIX-UPSTREAM bsc1263083-http-cookies-atob-utf8.patch bsc#1263083 [email protected] # Use decodeURIComponent() for UTF-8 support in js_output() Patch63: 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 +Patch64: CVE-2026-15806-HTTPPasswordMgr-scheme.patch +# PATCH-FIX-UPSTREAM CVE-2026-17084-stringprep-rfc3454.patch bsc#1276226 Matej Cepl <[email protected]> +# Don't consider Unicode codepoint attributes outside RFC 3454 +Patch65: 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 +Patch66: 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/Zstandard (gh#python/cpython!156003) +Patch67: CVE-2026-15310-bound-zipfile-decompression.patch ### END OF PATCHES BuildRequires: autoconf-archive BuildRequires: automake ++++++ CVE-2026-15310-bound-zipfile-decompression.patch ++++++ >From e116414a68038c1c52e983fabc2661f074bc0387 Mon Sep 17 00:00:00 2001 From: Petr Viktorin <[email protected]> Date: Mon, 31 Aug 2026 21:04:04 +0200 Subject: [PATCH 1/5] [3.15] gh-156002: Bound zipfile decompression for bzip2/LZMA/Zstandard (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, and Zstandard 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 | 42 +++++++++++++++++++ Lib/zipfile.py | 38 ++++++++++++++--- ...-08-18-13-54-05.gh-issue-156002.CcWXPP.rst | 4 ++ 3 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Security/2026-08-18-13-54-05.gh-issue-156002.CcWXPP.rst diff --git a/Lib/test/test_zipfile.py b/Lib/test/test_zipfile.py index 55e9792b36aa6c5..de97985cb2684b6 100644 --- a/Lib/test/test_zipfile.py +++ b/Lib/test/test_zipfile.py @@ -2198,6 +2198,48 @@ def tearDown(self): 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 + + +@requires_zstd() +class ZstdBoundedDecompressTests(AbstractBoundedDecompressTests, + unittest.TestCase): + compression = zipfile.ZIP_ZSTANDARD + + class AbstractBadCrcTests: def test_testzip_with_bad_crc(self): """Tests that files with bad CRCs return their name from testzip.""" diff --git a/Lib/zipfile.py b/Lib/zipfile.py index 38bf08064cfa41c..8352dc9ab47fbe5 100644 --- a/Lib/zipfile.py +++ b/Lib/zipfile.py @@ -640,7 +640,16 @@ def __init__(self): 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: @@ -656,7 +665,7 @@ def decompress(self, data): 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 @@ -716,6 +725,13 @@ def _get_compressor(compress_type, compresslevel=None): return None +def _decompressor_needs_input(decompressor): + # bz2/zstd expose the stdlib decompressor's public needs_input; the LZMA + # wrapper keeps it private (_needs_input) to avoid adding public API. + needs_input = getattr(decompressor, "needs_input", None) + return decompressor._needs_input if needs_input is None else needs_input + + def _get_decompressor(compress_type): _check_compression(compress_type) if compress_type == ZIP_STORED: @@ -1012,8 +1028,15 @@ def _read1(self, n): 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 _decompressor_needs_input(self._decompressor): + data = self._read2(n) + else: + data = b'' if self._compress_type == ZIP_STORED: self._eof = self._compress_left <= 0 @@ -1026,8 +1049,13 @@ def _read1(self, n): 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. + data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE)) + self._eof = (self._decompressor.eof or + self._compress_left <= 0 and + _decompressor_needs_input(self._decompressor)) data = data[:self._left] self._left -= len(data) diff --git a/Misc/NEWS.d/next/Security/2026-08-18-13-54-05.gh-issue-156002.CcWXPP.rst b/Misc/NEWS.d/next/Security/2026-08-18-13-54-05.gh-issue-156002.CcWXPP.rst new file mode 100644 index 000000000000000..4e49ad5ce8fa00a --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-08-18-13-54-05.gh-issue-156002.CcWXPP.rst @@ -0,0 +1,4 @@ +Bound the amount of data :mod:`zipfile` decompresses per read for members +compressed with bzip2, LZMA, or Zstandard, matching the existing limit for +deflate. A small archive member could previously expand into an unbounded +allocation even when read in small chunks. >From 3696a46be8c36cf708c1f88efa03fd8331f3733b Mon Sep 17 00:00:00 2001 From: Petr Viktorin <[email protected]> Date: Thu, 3 Sep 2026 16:39:39 +0200 Subject: [PATCH 2/5] Remove zstd test (3.14+) --- Lib/test/test_zipfile.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Lib/test/test_zipfile.py b/Lib/test/test_zipfile.py index de97985cb2684b6..6c4ba365eb0eea6 100644 --- a/Lib/test/test_zipfile.py +++ b/Lib/test/test_zipfile.py @@ -2234,12 +2234,6 @@ class LzmaBoundedDecompressTests(AbstractBoundedDecompressTests, compression = zipfile.ZIP_LZMA -@requires_zstd() -class ZstdBoundedDecompressTests(AbstractBoundedDecompressTests, - unittest.TestCase): - compression = zipfile.ZIP_ZSTANDARD - - class AbstractBadCrcTests: def test_testzip_with_bad_crc(self): """Tests that files with bad CRCs return their name from testzip.""" >From c6b8aabcb5c73aef402ad5d34257a7440dd09d06 Mon Sep 17 00:00:00 2001 From: "Miss Islington (bot)" <[email protected]> Date: Wed, 16 Sep 2026 06:52:44 -0700 Subject: [PATCH 3/5] gh-156002: Keep reading through monkey-patched zipfile decompressors (GH-157180) (GH-157557) (cherry picked from commit f507e6946a3194e83e1d7b8ee6e14567175e46de) Co-authored-by: Petr Viktorin <[email protected]> Co-authored-by: rasmusfaber <[email protected]> --- Lib/test/test_zipfile.py | 68 +++++++++++++++++++ Lib/zipfile.py | 19 +++--- ...-09-08-13-06-29.gh-issue-156002.vmOC8T.rst | 5 ++ 3 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst diff --git a/Lib/test/test_zipfile.py b/Lib/test/test_zipfile.py index 6c4ba365eb0eea6..1d069c156ffd402 100644 --- a/Lib/test/test_zipfile.py +++ b/Lib/test/test_zipfile.py @@ -2234,6 +2234,74 @@ class LzmaBoundedDecompressTests(AbstractBoundedDecompressTests, 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.""" diff --git a/Lib/zipfile.py b/Lib/zipfile.py index 8352dc9ab47fbe5..33beef209551544 100644 --- a/Lib/zipfile.py +++ b/Lib/zipfile.py @@ -641,7 +641,7 @@ def __init__(self): self.eof = False @property - def _needs_input(self): + 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. @@ -725,13 +725,6 @@ def _get_compressor(compress_type, compresslevel=None): return None -def _decompressor_needs_input(decompressor): - # bz2/zstd expose the stdlib decompressor's public needs_input; the LZMA - # wrapper keeps it private (_needs_input) to avoid adding public API. - needs_input = getattr(decompressor, "needs_input", None) - return decompressor._needs_input if needs_input is None else needs_input - - def _get_decompressor(compress_type): _check_compression(compress_type) if compress_type == ZIP_STORED: @@ -1033,7 +1026,7 @@ def _read1(self, n): else: # bzip2/lzma/zstd: a bounded decompress() call may leave input # buffered inside the decompressor; drain that before reading more. - if _decompressor_needs_input(self._decompressor): + if getattr(self._decompressor, "needs_input", True): data = self._read2(n) else: data = b'' @@ -1052,10 +1045,14 @@ def _read1(self, n): # 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. - data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE)) + try: + data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE)) + except TypeError: + # See MonkeypatchedDecompressorTests in test_core.py + data = self._decompressor.decompress(data) self._eof = (self._decompressor.eof or self._compress_left <= 0 and - _decompressor_needs_input(self._decompressor)) + getattr(self._decompressor, "needs_input", True)) data = data[:self._left] self._left -= len(data) diff --git a/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst b/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst new file mode 100644 index 000000000000000..a21386803cca0f4 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst @@ -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`. >From 14bfd82b3aae6218263d3f4057d292f64200a247 Mon Sep 17 00:00:00 2001 From: Petr Viktorin <[email protected]> Date: Wed, 16 Sep 2026 18:08:46 +0200 Subject: [PATCH 4/5] Avoid unittest's enterContext; it doesn't exist yet --- Lib/test/test_zipfile.py | 45 +++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/Lib/test/test_zipfile.py b/Lib/test/test_zipfile.py index 1d069c156ffd402..fea72705d7fd19c 100644 --- a/Lib/test/test_zipfile.py +++ b/Lib/test/test_zipfile.py @@ -2258,7 +2258,7 @@ class Decompressor: def decompress(self, data): return data.swapcase() - def setUp(self): + def test_roundtrip_monkeypatched_decompressor(self): orig_check_compression = zipfile._check_compression orig_get_compressor = zipfile._get_compressor orig_get_decompressor = zipfile._get_decompressor @@ -2277,29 +2277,26 @@ def get_decompressor(compress_type): 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) + with ( + mock.patch.object(zipfile, '_check_compression', check_compression), + mock.patch.object(zipfile, '_get_compressor', get_compressor), + mock.patch.object(zipfile, '_get_decompressor', get_decompressor), + ): + 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: >From 42b6c97424c4d9b4472640eac7d49026a733edd4 Mon Sep 17 00:00:00 2001 From: Petr Viktorin <[email protected]> Date: Wed, 16 Sep 2026 18:10:58 +0200 Subject: [PATCH 5/5] Don't use the :cve: RST role, it doesn't exist yet --- .../next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst b/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst index a21386803cca0f4..689967bff72fe7c 100644 --- a/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst +++ b/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst @@ -2,4 +2,4 @@ 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`. +``decompress()`` are vulnerable to CVE 2026-15310. ++++++ 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 +++- Misc/NEWS.d/next/Security/2026-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.10.21/Doc/library/urllib.request.rst =================================================================== --- Python-3.10.21.orig/Doc/library/urllib.request.rst 2026-08-13 01:03:10.000000000 +0200 +++ Python-3.10.21/Doc/library/urllib.request.rst 2026-09-10 23:41:06.501393971 +0200 @@ -922,8 +922,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.10.21/Lib/test/test_urllib2.py =================================================================== --- Python-3.10.21.orig/Lib/test/test_urllib2.py 2026-09-10 23:41:03.305710092 +0200 +++ Python-3.10.21/Lib/test/test_urllib2.py 2026-09-10 23:41:06.502157280 +0200 @@ -269,6 +269,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 = [] @@ -1712,6 +1756,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.10.21/Lib/urllib/request.py =================================================================== --- Python-3.10.21.orig/Lib/urllib/request.py 2026-09-10 23:41:06.370374750 +0200 +++ Python-3.10.21/Lib/urllib/request.py 2026-09-10 23:41:06.502739077 +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.10.21/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.10.21/Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst 2026-09-10 23:41:06.503190077 +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 ++++++ ++++ 948 lines (skipped) ++++++ CVE-2026-19672-tarfile-outside-dirs.patch ++++++ >From 18dfe068776daa57fe031d0ff698617c78eae41c Mon Sep 17 00:00:00 2001 From: Stan Ulbrych <[email protected]> Date: Wed, 19 Aug 2026 09:52:01 +0100 Subject: [PATCH] [3.10] 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.10.21/Doc/library/tarfile.rst =================================================================== --- Python-3.10.21.orig/Doc/library/tarfile.rst 2026-08-13 01:03:10.000000000 +0200 +++ Python-3.10.21/Doc/library/tarfile.rst 2026-09-15 15:56:31.165426394 +0200 @@ -950,6 +950,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`. @@ -958,6 +962,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.10.21/Lib/tarfile.py =================================================================== --- Python-3.10.21.orig/Lib/tarfile.py 2026-09-15 15:56:24.404294125 +0200 +++ Python-3.10.21/Lib/tarfile.py 2026-09-15 15:56:31.165790722 +0200 @@ -776,6 +776,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.10.21/Lib/test/test_tarfile.py =================================================================== --- Python-3.10.21.orig/Lib/test/test_tarfile.py 2026-09-15 15:56:25.962481162 +0200 +++ Python-3.10.21/Lib/test/test_tarfile.py 2026-09-15 15:56:31.166455367 +0200 @@ -3535,6 +3535,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') + def test_parent_symlink(self): # Test interplaying symlinks # Inspired by 'dirsymlink2a' in jwilk/traversal-archives Index: Python-3.10.21/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.10.21/Misc/NEWS.d/next/Security/2026-08-13-13-08-11.gh-issue-155999.Xt4rWq.rst 2026-09-15 15:56:31.167223859 +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.frFgMc/_old 2026-10-01 16:47:17.334653961 +0200 +++ /var/tmp/diff_new_pack.frFgMc/_new 2026-10-01 16:47:17.338654129 +0200 @@ -1,6 +1,6 @@ -mtime: 1788936080 -commit: 5ac7890be8a1b433c6e1437f48b318684ebe7821d015bcc5e050b25eec297bef +mtime: 1789741924 +commit: 8e25bf4dd8802cd9baae7d8d418daa809ba1acc9d825b4d663d35fb4892e11a6 url: https://src.opensuse.org/python-interpreters/python310 -revision: 5ac7890be8a1b433c6e1437f48b318684ebe7821d015bcc5e050b25eec297bef +revision: 8e25bf4dd8802cd9baae7d8d418daa809ba1acc9d825b4d663d35fb4892e11a6 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 16:32:04.000000000 +0200 @@ -0,0 +1,7 @@ +.osc +*.obscpio +*.osc +_build.* +.pbuild +*.rej +python310*-build/
