Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python312 for openSUSE:Factory checked in at 2026-08-01 18:28:21 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python312 (Old) and /work/SRC/openSUSE:Factory/.python312.new.16738 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python312" Sat Aug 1 18:28:21 2026 rev:51 rq:1368858 version:3.12.13 Changes: -------- --- /work/SRC/openSUSE:Factory/python312/python312.changes 2026-07-02 20:07:48.213949367 +0200 +++ /work/SRC/openSUSE:Factory/.python312.new.16738/python312.changes 2026-08-01 18:28:54.534910693 +0200 @@ -1,0 +2,70 @@ +Fri Jul 31 13:55:17 UTC 2026 - Matej Cepl <[email protected]> + +- CVE-2026-3276: Fix O(n^2) canonical ordering in + unicodedata.normalize() (bsc#1267581, gh#python/cpython#149079) +CVE-2026-3276-On2-unicodedata-normalize.patch + +------------------------------------------------------------------- +Fri Jul 31 13:42:27 UTC 2026 - Matej Cepl <[email protected]> + +- CVE-2026-7774: tarfile.data_filter: validate written link + target (bsc#1267821, gh#149486) + CVE-2026-7774-tarfile-data_filter-symlink.patch + +------------------------------------------------------------------- +Fri Jul 31 13:35:09 UTC 2026 - Matej Cepl <[email protected]> + +- CVE-2026-0864: Normalize all line endings (CR, CRLF, and LF) in + configparser (bsc#1269066, gh#python/cpython#143927) + CVE-2026-0864-normalize-LFTAB-configparser.patch + +------------------------------------------------------------------- +Fri Jul 31 13:22:45 UTC 2026 - Matej Cepl <[email protected]> + +- CVE-2026-11972: Make tarfile._Stream.seek break at EOF + (bsc#1269788, gh#python/cpython#151981) + CVE-2026-11972-tarfile-Stream-seek-EOF.patch + +------------------------------------------------------------------- +Fri Jul 31 13:07:50 UTC 2026 - Matej Cepl <[email protected]> + +- CVE-2026-4360: Pass filter_function to TarFile._extract_one() + during .extract() (bsc#1269959, gh#python/cpython#151987) + CVE-2026-4360-filter_function-TarFile-extractone.patch + +------------------------------------------------------------------- +Fri Jul 31 12:54:51 UTC 2026 - Matej Cepl <[email protected]> + +- CVE-2026-15308: Fix quadratic complexity in incremental parsing + in HTMLParser (bsc#1271192, gh#python/cpython#153030) + CVE-2026-15308-HTMLParser-CPU-exhaust.patch + +------------------------------------------------------------------- +Thu Jul 23 04:58:44 UTC 2026 - Steve Kowalik <[email protected]> + +- Add patch support-sphinx-9.patch: + * Support changes required for Sphinx 9. + +------------------------------------------------------------------- +Sat Jul 4 10:25:43 UTC 2026 - Matej Cepl <[email protected]> + +- CVE-2026-7210: Use XML_SetHashSalt16Bytes in + pyexpat/_elementtree when possible (bsc#1264962, + gh#python/cpython#149018). + CVE-2026-7210-pyexpat-entropy-hash-flooding.patch + +------------------------------------------------------------------- +Thu Jul 2 22:49:04 UTC 2026 - Matej Cepl <[email protected]> + +- CVE-2026-8328: Make ftplib not trust the PASV response + (bsc#1265268, gh#python/cpython#87451) + CVE-2026-8328-ftplib-no-trust-PASV-resp.patch + +------------------------------------------------------------------- +Wed Jul 1 23:10:26 UTC 2026 - Matej Cepl <[email protected]> + +- CVE-2026-11940: fix the symlink escape via tarfile + hardlink-extraction fallback (bsc#1268977) + CVE-2026-11940-tarfile-escape.patch + +------------------------------------------------------------------- New: ---- CVE-2026-0864-normalize-LFTAB-configparser.patch CVE-2026-11940-tarfile-escape.patch CVE-2026-11972-tarfile-Stream-seek-EOF.patch CVE-2026-15308-HTMLParser-CPU-exhaust.patch CVE-2026-3276-On2-unicodedata-normalize.patch CVE-2026-4360-filter_function-TarFile-extractone.patch CVE-2026-7210-pyexpat-entropy-hash-flooding.patch CVE-2026-7774-tarfile-data_filter-symlink.patch CVE-2026-8328-ftplib-no-trust-PASV-resp.patch support-sphinx-9.patch ----------(New B)---------- New: configparser (bsc#1269066, gh#python/cpython#143927) CVE-2026-0864-normalize-LFTAB-configparser.patch New: hardlink-extraction fallback (bsc#1268977) CVE-2026-11940-tarfile-escape.patch New: (bsc#1269788, gh#python/cpython#151981) CVE-2026-11972-tarfile-Stream-seek-EOF.patch New: in HTMLParser (bsc#1271192, gh#python/cpython#153030) CVE-2026-15308-HTMLParser-CPU-exhaust.patch New: unicodedata.normalize() (bsc#1267581, gh#python/cpython#149079) CVE-2026-3276-On2-unicodedata-normalize.patch New: during .extract() (bsc#1269959, gh#python/cpython#151987) CVE-2026-4360-filter_function-TarFile-extractone.patch New: gh#python/cpython#149018). CVE-2026-7210-pyexpat-entropy-hash-flooding.patch New: target (bsc#1267821, gh#149486) CVE-2026-7774-tarfile-data_filter-symlink.patch New: (bsc#1265268, gh#python/cpython#87451) CVE-2026-8328-ftplib-no-trust-PASV-resp.patch New: - Add patch support-sphinx-9.patch: * Support changes required for Sphinx 9. ----------(New E)---------- ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python312.spec ++++++ --- /var/tmp/diff_new_pack.53gIG0/_old 2026-08-01 18:28:59.095066053 +0200 +++ /var/tmp/diff_new_pack.53gIG0/_new 2026-08-01 18:28:59.095066053 +0200 @@ -236,6 +236,35 @@ # PATCH-FIX-UPSTREAM CVE-2026-6019-Morsel-js_output.patch bsc#1262654 [email protected] # Base64-encode cookie values embedded in JS Patch69: CVE-2026-6019-Morsel-js_output.patch +# PATCH-FIX-UPSTREAM CVE-2026-11940-tarfile-escape.patch bsc#1268977 [email protected] +# Fix symlink escape via tarfile hardlink-extraction fallback +Patch70: CVE-2026-11940-tarfile-escape.patch +# PATCH-FIX-UPSTREAM CVE-2026-8328-ftplib-no-trust-PASV-resp.patch bsc#1265268 [email protected] +# Make ftplib not trust the PASV response +Patch71: CVE-2026-8328-ftplib-no-trust-PASV-resp.patch +# PATCH-FIX-UPSTREAM CVE-2026-7210-pyexpat-entropy-hash-flooding.patch bsc#1264962 [email protected] +# Use XML_SetHashSalt16Bytes in pyexpat/_elementtree when possible +Patch72: CVE-2026-7210-pyexpat-entropy-hash-flooding.patch +# PATCH-FIX-UPSTREAM Based on gh#python/cpython#142057 Support fixes required for Sphinx 9 +Patch73: support-sphinx-9.patch +# PATCH-FIX-UPSTREAM CVE-2026-15308-HTMLParser-CPU-exhaust.patch bsc#1271192 [email protected] +# Fix quadratic complexity in incremental parsing in HTMLParser +Patch74: CVE-2026-15308-HTMLParser-CPU-exhaust.patch +# PATCH-FIX-UPSTREAM CVE-2026-4360-filter_function-TarFile-extractone.patch bsc#1269959 [email protected] +# Pass filter_function to TarFile._extract_one() during .extract() +Patch75: CVE-2026-4360-filter_function-TarFile-extractone.patch +# PATCH-FIX-UPSTREAM CVE-2026-11972-tarfile-Stream-seek-EOF.patch bsc#1269788 [email protected] +# Make tarfile._Stream.seek break at EOF +Patch76: CVE-2026-11972-tarfile-Stream-seek-EOF.patch +# PATCH-FIX-UPSTREAM CVE-2026-0864-normalize-LFTAB-configparser.patch bsc#1269066 [email protected] +# Normalize all line endings (CR, CRLF, and LF) in configparser +Patch77: CVE-2026-0864-normalize-LFTAB-configparser.patch +# PATCH-FIX-UPSTREAM CVE-2026-7774-tarfile-data_filter-symlink.patch bsc#1267821 [email protected] +# tarfile.data_filter: validate written link target +Patch78: CVE-2026-7774-tarfile-data_filter-symlink.patch +# PATCH-FIX-UPSTREAM CVE-2026-3276-On2-unicodedata-normalize.patch bsc#1267581 [email protected] +# gh-149079: Fix O(n^2) canonical ordering in unicodedata.normalize() +Patch79: CVE-2026-3276-On2-unicodedata-normalize.patch ### END OF PATCHES BuildRequires: autoconf-archive BuildRequires: automake ++++++ CVE-2026-0864-normalize-LFTAB-configparser.patch ++++++ >From 2c2aeeac9b2e4838f24456993142a222de45aa5a Mon Sep 17 00:00:00 2001 From: Seth Larson <[email protected]> Date: Tue, 23 Jun 2026 08:33:51 -0500 Subject: [PATCH] gh-143927: Normalize all line endings (CR, CRLF, and LF) in configparser (GH-143929) (cherry picked from commit 5858e42c539dac8394636a6e9b30472b8994851f) Co-authored-by: Seth Larson <[email protected]> --- Lib/configparser.py | 4 ++- Lib/test/test_configparser.py | 11 ++++++++++ Misc/NEWS.d/next/Security/2026-01-16-11-58-19.gh-issue-143927.aviFeG.rst | 2 + 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Security/2026-01-16-11-58-19.gh-issue-143927.aviFeG.rst Index: Python-3.12.13/Lib/configparser.py =================================================================== --- Python-3.12.13.orig/Lib/configparser.py 2026-07-31 15:34:50.123179939 +0200 +++ Python-3.12.13/Lib/configparser.py 2026-07-31 15:35:00.535569373 +0200 @@ -907,7 +907,9 @@ value = self._interpolation.before_write(self, section_name, key, value) if value is not None or not self._allow_no_value: - value = delimiter + str(value).replace('\n', '\n\t') + # Convert all possible line-endings into '\n\t' + value = (delimiter + str(value).replace('\r\n', '\n') + .replace('\r', '\n').replace('\n', '\n\t')) else: value = "" fp.write("{}{}\n".format(key, value)) Index: Python-3.12.13/Lib/test/test_configparser.py =================================================================== --- Python-3.12.13.orig/Lib/test/test_configparser.py 2026-07-31 15:34:51.948621214 +0200 +++ Python-3.12.13/Lib/test/test_configparser.py 2026-07-31 15:35:00.535885028 +0200 @@ -527,6 +527,17 @@ cf.get(self.default_section, "Foo"), "Bar", "could not locate option, expecting case-insensitive defaults") + def test_crlf_normalization(self): + cf = self.newconfig({"key1": "a\nb","key2": "a\rb", "key3": "a\r\nb", "key4": "a\r\nb"}) + buf = io.StringIO() + cf.write(buf) + cf_str = buf.getvalue() + self.assertNotIn("\r", cf_str) + self.assertNotIn("\r\n", cf_str) + self.assertEqual(cf_str.count("\n"), 10) + self.assertEqual(cf_str.count("\n\t"), 4) + self.assertTrue(cf_str.endswith("\n\n")) + def test_parse_errors(self): cf = self.newconfig() self.parse_error(cf, configparser.ParsingError, Index: Python-3.12.13/Misc/NEWS.d/next/Security/2026-01-16-11-58-19.gh-issue-143927.aviFeG.rst =================================================================== --- /dev/null 1970-01-01 00:00:00.000000000 +0000 +++ Python-3.12.13/Misc/NEWS.d/next/Security/2026-01-16-11-58-19.gh-issue-143927.aviFeG.rst 2026-07-31 15:35:00.536155047 +0200 @@ -0,0 +1,2 @@ +Normalize all line endings (CR, CRLF, and LF) to LF+TAB when writing +multi-line configparser values. ++++++ CVE-2026-11940-tarfile-escape.patch ++++++ >From d0ba16dce6df2f68e8968699d5c247eddeef9960 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych <[email protected]> Date: Tue, 23 Jun 2026 14:31:38 +0100 Subject: [PATCH] gh-151558: Fix symlink escape via `tarfile` hardlink-extraction fallback (GH-151559) (cherry picked from commit 27dd970bf6b17ebca7c8ed486a40ab043ed7af8f) Co-authored-by: Stan Ulbrych <[email protected]> --- Lib/tarfile.py | 3 + Lib/test/test_tarfile.py | 24 ++++++++++ Misc/NEWS.d/next/Security/2026-06-10-13-08-19.gh-issue-151558.mL74i2.rst | 3 + 3 files changed, 30 insertions(+) create mode 100644 Misc/NEWS.d/next/Security/2026-06-10-13-08-19.gh-issue-151558.mL74i2.rst Index: Python-3.12.13/Lib/tarfile.py =================================================================== --- Python-3.12.13.orig/Lib/tarfile.py 2026-07-02 12:07:53.963703857 +0200 +++ Python-3.12.13/Lib/tarfile.py 2026-07-02 12:07:54.089227749 +0200 @@ -2670,6 +2670,9 @@ "makelink_with_filter: if filter_function is not None, " + "extraction_root must also not be None") try: + filter_function( + unfiltered.replace(name=tarinfo.name, deep=False), + extraction_root) filtered = filter_function(unfiltered, extraction_root) except _FILTER_ERRORS as cause: raise LinkFallbackError(tarinfo, unfiltered.name) from cause Index: Python-3.12.13/Lib/test/test_tarfile.py =================================================================== --- Python-3.12.13.orig/Lib/test/test_tarfile.py 2026-07-02 12:07:53.964225393 +0200 +++ Python-3.12.13/Lib/test/test_tarfile.py 2026-07-02 12:07:54.090227768 +0200 @@ -4100,6 +4100,30 @@ self.expect_file("c", symlink_to='b') @symlink_test + def test_sneaky_hardlink_fallback_deep(self): + # (CVE-2026-11940) + with ArchiveMaker() as arc: + arc.add("a/b/s", symlink_to=os.path.join("..", "escape")) + arc.add("s", hardlink_to=os.path.join("a", "b", "s")) + + with self.check_context(arc.open(), 'data'): + e = self.expect_exception( + tarfile.LinkFallbackError, + "link 's' would be extracted as a copy of " + + "'a/b/s', which was rejected") + self.assertIsInstance(e.__cause__, + tarfile.LinkOutsideDestinationError) + + for filter in 'tar', 'fully_trusted': + with self.subTest(filter), self.check_context(arc.open(), filter): + if not os_helper.can_symlink(): + self.expect_file("a/") + self.expect_file("a/b/") + else: + self.expect_file("a/b/s", symlink_to=os.path.join('..', 'escape')) + self.expect_file("s", symlink_to=os.path.join('..', 'escape')) + + @symlink_test def test_exfiltration_via_symlink(self): # (CVE-2025-4138) # Test changing symlinks that result in a symlink pointing outside Index: Python-3.12.13/Misc/NEWS.d/next/Security/2026-06-10-13-08-19.gh-issue-151558.mL74i2.rst =================================================================== --- /dev/null 1970-01-01 00:00:00.000000000 +0000 +++ Python-3.12.13/Misc/NEWS.d/next/Security/2026-06-10-13-08-19.gh-issue-151558.mL74i2.rst 2026-07-02 12:07:54.090551855 +0200 @@ -0,0 +1,3 @@ +Fixed an vulnerability in the :mod:`tarfile` ``data`` and ``tar`` extraction +filters where crafted archives could create a symlink pointing outside the +destination directory. This was a bypass of :cve:`2025-4330`. ++++++ CVE-2026-11972-tarfile-Stream-seek-EOF.patch ++++++ >From 8f833e43ff025bf492b714de4b0138a51be68973 Mon Sep 17 00:00:00 2001 From: Petr Viktorin <[email protected]> Date: Tue, 23 Jun 2026 15:13:30 +0200 Subject: [PATCH] gh-151981: Make tarfile._Stream.seek break at EOF (GH-151982) (cherry picked from commit f50bf13566189c8d0ce5a814f33eff3d89951896) Co-authored-by: Petr Viktorin <[email protected]> Co-authored-by: Stan Ulbrych <[email protected]> --- Lib/tarfile.py | 4 +- Lib/test/test_tarfile.py | 16 ++++++++++ Misc/NEWS.d/next/Security/2026-06-23-13-28-16.gh-issue-151981.xBHEcU.rst | 2 + 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Security/2026-06-23-13-28-16.gh-issue-151981.xBHEcU.rst Index: Python-3.12.13/Lib/tarfile.py =================================================================== --- Python-3.12.13.orig/Lib/tarfile.py 2026-07-31 15:24:32.499837215 +0200 +++ Python-3.12.13/Lib/tarfile.py 2026-07-31 15:24:32.514837359 +0200 @@ -515,7 +515,9 @@ if pos - self.pos >= 0: blocks, remainder = divmod(pos - self.pos, self.bufsize) for i in range(blocks): - self.read(self.bufsize) + data = self.read(self.bufsize) + if not data: + break self.read(remainder) else: raise StreamError("seeking backwards is not allowed") Index: Python-3.12.13/Lib/test/test_tarfile.py =================================================================== --- Python-3.12.13.orig/Lib/test/test_tarfile.py 2026-07-31 15:24:32.501112240 +0200 +++ Python-3.12.13/Lib/test/test_tarfile.py 2026-07-31 15:24:32.516123600 +0200 @@ -4591,6 +4591,22 @@ with self.check_context(arc.open(errorlevel='boo!'), filtererror_filter): self.expect_exception(TypeError) # errorlevel is not int + @support.subTests('format', [tarfile.GNU_FORMAT, tarfile.PAX_FORMAT]) + def test_getmembers_big_size(self, format): + # gh-151981: A loop in seek() for streaming files tried to read the + # declared number of blocks even at EOF + tinfo = tarfile.TarInfo("huge-file") + tinfo.size = 1 << 64 + bio = io.BytesIO() + # Write header without data + bio.write(tinfo.tobuf(format)) + + # Reset & try to get contents + bio.seek(0) + with tarfile.open(fileobj=bio, mode="r|") as tar: + with self.assertRaises(tarfile.ReadError): + tar.getmembers() + class OverwriteTests(archiver_tests.OverwriteTests, unittest.TestCase): testdir = os.path.join(TEMPDIR, "testoverwrite") Index: Python-3.12.13/Misc/NEWS.d/next/Security/2026-06-23-13-28-16.gh-issue-151981.xBHEcU.rst =================================================================== --- /dev/null 1970-01-01 00:00:00.000000000 +0000 +++ Python-3.12.13/Misc/NEWS.d/next/Security/2026-06-23-13-28-16.gh-issue-151981.xBHEcU.rst 2026-07-31 15:24:32.516651254 +0200 @@ -0,0 +1,2 @@ +In :mod:`tarfile`, seeking a stream now stops when end of the stream is +reached. ++++++ CVE-2026-15308-HTMLParser-CPU-exhaust.patch ++++++ >From effae3f6f868409068cc0b7ab16fe8e4251352be Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka <[email protected]> Date: Sat, 4 Jul 2026 20:40:22 +0300 Subject: [PATCH] gh-153030: Fix quadratic complexity in incremental parsing in HTMLParser (GH-153031) When an unterminated construct (e.g. a tag or comment) spanned many feed() calls, rescanning the growing buffer and concatenating new data onto it were both quadratic. New data is now accumulated in a list and only joined and parsed once enough has piled up. (cherry picked from commit bcf98ddbc40ec9b3ee87da0124a5660b19b7e606) Co-authored-by: Serhiy Storchaka <[email protected]> Co-Authored-By: Claude Opus 4.8 <[email protected]> --- Lib/html/parser.py | 32 +++++++++- Lib/test/test_htmlparser.py | 20 ++++++ Misc/NEWS.d/next/Security/2026-07-04-17-00-00.gh-issue-153030.RovkP6.rst | 3 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Security/2026-07-04-17-00-00.gh-issue-153030.RovkP6.rst Index: Python-3.12.13/Lib/html/parser.py =================================================================== --- Python-3.12.13.orig/Lib/html/parser.py 2026-07-31 14:56:12.461798461 +0200 +++ Python-3.12.13/Lib/html/parser.py 2026-07-31 14:56:18.773048016 +0200 @@ -138,6 +138,9 @@ self.cdata_elem = None self._support_cdata = True self._escapable = True + self._pending = [] + self._pending_len = 0 + self._parse_threshold = 1 super().reset() def feed(self, data): @@ -146,11 +149,36 @@ Call this as often as you want, with as little or as much text as you want (may include '\n'). """ - self.rawdata = self.rawdata + data - self.goahead(0) + # Accumulate new data in a list and only join and parse it once + # enough has piled up. Rescanning an unparsed buffer (e.g. an + # unterminated tag) and concatenating onto it on every call would + # both be quadratic in the input size. + self._pending_len += len(data) + if self._pending_len < self._parse_threshold: + self._pending.append(data) + else: + if not self._pending: + self.rawdata += data + else: + self._pending.append(data) + self.rawdata += ''.join(self._pending) + self._pending.clear() + self._pending_len = 0 + n = len(self.rawdata) + self.goahead(0) + if len(self.rawdata) < n: + # Some data was parsed; resume on the next call. + self._parse_threshold = 1 + else: + # Nothing was parsed; wait until the buffer doubles. + self._parse_threshold = len(self.rawdata) def close(self): """Handle any buffered data.""" + if self._pending: + self.rawdata += ''.join(self._pending) + self._pending.clear() + self._pending_len = 0 self.goahead(1) __starttag_text = None Index: Python-3.12.13/Lib/test/test_htmlparser.py =================================================================== --- Python-3.12.13.orig/Lib/test/test_htmlparser.py 2026-07-31 14:56:14.511914931 +0200 +++ Python-3.12.13/Lib/test/test_htmlparser.py 2026-07-31 14:56:18.773765745 +0200 @@ -930,6 +930,26 @@ check("<![CDATA[" * 9 * n) check("<!doctype" * 35 * n) + @support.requires_resource('cpu') + def test_incremental_no_quadratic_complexity(self): + # An unterminated construct fed in many small chunks used to take + # quadratic time, both to rescan and to concatenate the buffer. + # Now it takes a fraction of a second. + def check(prefix, chunk, suffix): + parser = html.parser.HTMLParser() + parser.feed(prefix) + for _ in range(200_000): + parser.feed(chunk) + parser.feed(suffix) + parser.close() + chunk = "a" * 64 + check("<!--", chunk, "-->") # comment + check("<?", chunk, ">") # processing instruction + check("<!doctype ", chunk, ">") # doctype + check("<![CDATA[", chunk, "]]>") # CDATA section + check("<a href='", chunk, "'>") # start tag + check("<script>", chunk, "</script>") # RAWTEXT element + class AttributesTestCase(TestCaseBase): Index: Python-3.12.13/Misc/NEWS.d/next/Security/2026-07-04-17-00-00.gh-issue-153030.RovkP6.rst =================================================================== --- /dev/null 1970-01-01 00:00:00.000000000 +0000 +++ Python-3.12.13/Misc/NEWS.d/next/Security/2026-07-04-17-00-00.gh-issue-153030.RovkP6.rst 2026-07-31 14:56:18.774005106 +0200 @@ -0,0 +1,3 @@ +Fixed quadratic complexity in incremental parsing of long unterminated +constructs (such as tags or comments) in :class:`html.parser.HTMLParser`, +which could be exploited for a denial of service. ++++++ CVE-2026-3276-On2-unicodedata-normalize.patch ++++++ >From d8deb45e25f8eae007449f4018a4f1e117a772d4 Mon Sep 17 00:00:00 2001 From: Petr Viktorin <[email protected]> Date: Tue, 2 Jun 2026 18:12:42 +0200 Subject: [PATCH] [3.12] gh-149079: Fix O(n^2) canonical ordering in unicodedata.normalize() (GH-149080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the insertion sort used for canonical ordering of combining characters with a hybrid approach: insertion sort for short runs (< 20) and counting sort for longer runs, reducing worst-case complexity from O(n^2) to O(n). This prevents denial of service via crafted Unicode strings with many combining characters in alternating CCC order. (cherry picked from commit 991224b1e8311c85f198f6dd8208bf8cff7fc26f) Co-authored-by: Seth Larson <[email protected]> Co-authored-by: ch4n3-yoon <[email protected]> Co-authored-by: Seokchan Yoon <[email protected]> Co-authored-by: Stan Ulbrych <[email protected]> Co-authored-by: Bénédikt Tran <[email protected]> Co-authored-by: Serhiy Storchaka <[email protected]> Co-authored-by: Maurycy Pawłowski-Wieroński <[email protected]> --- Lib/test/test_unicodedata.py | 28 + Misc/NEWS.d/next/Security/2026-04-27-16-36-11.gh-issue-149079.vKl-LM.rst | 5 Modules/unicodedata.c | 144 ++++++++-- 3 files changed, 151 insertions(+), 26 deletions(-) create mode 100644 Misc/NEWS.d/next/Security/2026-04-27-16-36-11.gh-issue-149079.vKl-LM.rst Index: Python-3.12.13/Lib/test/test_unicodedata.py =================================================================== --- Python-3.12.13.orig/Lib/test/test_unicodedata.py 2026-07-31 15:54:54.381130595 +0200 +++ Python-3.12.13/Lib/test/test_unicodedata.py 2026-07-31 15:55:01.273145567 +0200 @@ -203,6 +203,34 @@ b = 'C\u0338' * 20 + '\xC7' self.assertEqual(self.db.normalize('NFC', a), b) + def test_long_combining_mark_run(self): + # gh-149079: avoid quadratic canonical ordering. + payload = "a" + ("\u0300\u0327" * 32) + nfd = "a" + ("\u0327" * 32) + ("\u0300" * 32) + nfc = "\u00e0" + ("\u0327" * 32) + ("\u0300" * 31) + + self.assertEqual(self.db.normalize("NFD", payload), nfd) + self.assertEqual(self.db.normalize("NFKD", payload), nfd) + self.assertEqual(self.db.normalize("NFC", payload), nfc) + self.assertEqual(self.db.normalize("NFKC", payload), nfc) + + def test_combining_mark_run_fast_paths(self): + # gh-149079: cover short runs and already-sorted long runs. + short_payload = "a" + ("\u0300\u0327" * 9) + "\u0300" + short_nfd = "a" + ("\u0327" * 9) + ("\u0300" * 10) + short_nfc = "\u00e0" + ("\u0327" * 9) + ("\u0300" * 9) + long_sorted = "a" + ("\u0327" * 30) + ("\u0300" * 30) + long_sorted_nfc = "\u00e0" + ("\u0327" * 30) + ("\u0300" * 29) + + self.assertEqual(self.db.normalize("NFD", short_payload), short_nfd) + self.assertEqual(self.db.normalize("NFKD", short_payload), short_nfd) + self.assertEqual(self.db.normalize("NFC", short_payload), short_nfc) + self.assertEqual(self.db.normalize("NFKC", short_payload), short_nfc) + self.assertEqual(self.db.normalize("NFD", long_sorted), long_sorted) + self.assertEqual(self.db.normalize("NFKD", long_sorted), long_sorted) + self.assertEqual(self.db.normalize("NFC", long_sorted), long_sorted_nfc) + self.assertEqual(self.db.normalize("NFKC", long_sorted), long_sorted_nfc) + def test_issue29456(self): # Fix #29456 u1176_str_a = '\u1100\u1176\u11a8' Index: Python-3.12.13/Misc/NEWS.d/next/Security/2026-04-27-16-36-11.gh-issue-149079.vKl-LM.rst =================================================================== --- /dev/null 1970-01-01 00:00:00.000000000 +0000 +++ Python-3.12.13/Misc/NEWS.d/next/Security/2026-04-27-16-36-11.gh-issue-149079.vKl-LM.rst 2026-07-31 15:55:01.273321169 +0200 @@ -0,0 +1,5 @@ +Fix a potential denial of service in :func:`unicodedata.normalize`. The +canonical ordering step of Unicode normalization used a quadratic-time insertion +sort for reordering combining characters, which could be exploited with +crafted input containing many combining characters in non-canonical order. +Replaced with a linear-time counting sort for long runs. Index: Python-3.12.13/Modules/unicodedata.c =================================================================== --- Python-3.12.13.orig/Modules/unicodedata.c 2026-03-03 13:39:30.000000000 +0100 +++ Python-3.12.13/Modules/unicodedata.c 2026-07-31 15:55:01.273759706 +0200 @@ -490,19 +490,80 @@ #define NCount (VCount*TCount) #define SCount (LCount*NCount) +/* Small combining runs are usually cheaper with insertion sort. */ +#define CANONICAL_ORDERING_COUNTING_SORT_THRESHOLD 20 + +static void +canonical_ordering_sort_insertion(int kind, void *data, + Py_ssize_t start, Py_ssize_t end) +{ + for (Py_ssize_t i = start + 1; i < end; i++) { + Py_UCS4 code = PyUnicode_READ(kind, data, i); + unsigned char combining = _getrecord_ex(code)->combining; + Py_ssize_t j = i; + + while (j > start) { + Py_UCS4 previous = PyUnicode_READ(kind, data, j - 1); + if (_getrecord_ex(previous)->combining <= combining) { + break; + } + PyUnicode_WRITE(kind, data, j, previous); + j--; + } + if (j != i) { + PyUnicode_WRITE(kind, data, j, code); + } + } +} + +static void +canonical_ordering_sort_counting(int kind, void *data, + Py_ssize_t start, Py_ssize_t end, + Py_UCS4 *sortbuf) +{ + Py_ssize_t counts[256] = {0}; + Py_ssize_t run_length = end - start; + Py_ssize_t total = 0; + + for (Py_ssize_t i = start; i < end; i++) { + Py_UCS4 code = PyUnicode_READ(kind, data, i); + unsigned char combining = _getrecord_ex(code)->combining; + counts[combining]++; + } + + for (size_t i = 0; i < Py_ARRAY_LENGTH(counts); i++) { + Py_ssize_t count = counts[i]; + counts[i] = total; + total += count; + } + + /* Reuse counts[] as the next output slot for each CCC. */ + for (Py_ssize_t i = start; i < end; i++) { + Py_UCS4 code = PyUnicode_READ(kind, data, i); + unsigned char combining = _getrecord_ex(code)->combining; + sortbuf[counts[combining]++] = code; + } + for (Py_ssize_t i = 0; i < run_length; i++) { + PyUnicode_WRITE(kind, data, start + i, sortbuf[i]); + } +} + static PyObject* nfd_nfkd(PyObject *self, PyObject *input, int k) { PyObject *result; Py_UCS4 *output; Py_ssize_t i, o, osize; - int kind; - const void *data; + int input_kind, result_kind; + const void *input_data; + void *result_data; /* Longest decomposition in Unicode 3.2: U+FDFA */ Py_UCS4 stack[20]; Py_ssize_t space, isize; int index, prefix, count, stackptr; unsigned char prev, cur; + Py_UCS4 *sortbuf = NULL; + Py_ssize_t sortbuflen = 0; stackptr = 0; isize = PyUnicode_GET_LENGTH(input); @@ -522,11 +583,11 @@ return NULL; } i = o = 0; - kind = PyUnicode_KIND(input); - data = PyUnicode_DATA(input); + input_kind = PyUnicode_KIND(input); + input_data = PyUnicode_DATA(input); while (i < isize) { - stack[stackptr++] = PyUnicode_READ(kind, data, i++); + stack[stackptr++] = PyUnicode_READ(input_kind, input_data, i++); while(stackptr) { Py_UCS4 code = stack[--stackptr]; /* Hangul Decomposition adds three characters in @@ -591,35 +652,66 @@ PyMem_Free(output); if (!result) return NULL; + /* result is guaranteed to be ready, as it is compact. */ - kind = PyUnicode_KIND(result); - data = PyUnicode_DATA(result); + result_kind = PyUnicode_KIND(result); + result_data = PyUnicode_DATA(result); - /* Sort canonically. */ + /* Sort each consecutive combining-character run canonically. */ i = 0; - prev = _getrecord_ex(PyUnicode_READ(kind, data, i))->combining; - for (i++; i < PyUnicode_GET_LENGTH(result); i++) { - cur = _getrecord_ex(PyUnicode_READ(kind, data, i))->combining; - if (prev == 0 || cur == 0 || prev <= cur) { - prev = cur; + while (i < o) { + Py_ssize_t run_length, run_start; + int needs_sort = 0; + + Py_UCS4 ch = PyUnicode_READ(result_kind, result_data, i); + prev = _getrecord_ex(ch)->combining; + if (prev == 0) { + i++; continue; } - /* Non-canonical order. Need to switch *i with previous. */ - o = i - 1; - while (1) { - Py_UCS4 tmp = PyUnicode_READ(kind, data, o+1); - PyUnicode_WRITE(kind, data, o+1, - PyUnicode_READ(kind, data, o)); - PyUnicode_WRITE(kind, data, o, tmp); - o--; - if (o < 0) - break; - prev = _getrecord_ex(PyUnicode_READ(kind, data, o))->combining; - if (prev == 0 || prev <= cur) + + run_start = i++; + while (i < o) { + Py_UCS4 ch = PyUnicode_READ(result_kind, result_data, i); + cur = _getrecord_ex(ch)->combining; + if (cur == 0) { break; + } + if (prev > cur) { + needs_sort = 1; + } + prev = cur; + i++; } - prev = _getrecord_ex(PyUnicode_READ(kind, data, i))->combining; + if (!needs_sort) { + continue; + } + + run_length = i - run_start; + if (run_length < CANONICAL_ORDERING_COUNTING_SORT_THRESHOLD) { + canonical_ordering_sort_insertion(result_kind, result_data, + run_start, i); + continue; + } + + if (run_length > sortbuflen) { + Py_UCS4 *new_sortbuf = PyMem_Resize(sortbuf, + Py_UCS4, + run_length); + if (new_sortbuf == NULL) { + PyErr_NoMemory(); + PyMem_Free(sortbuf); + Py_DECREF(result); + return NULL; + } + sortbuf = new_sortbuf; + sortbuflen = run_length; + } + + canonical_ordering_sort_counting(result_kind, result_data, + run_start, i, sortbuf); } + PyMem_Free(sortbuf); return result; } ++++++ CVE-2026-4360-filter_function-TarFile-extractone.patch ++++++ >From 61d17f4516bc3284b59c6ec01ee2e81da619f4ff Mon Sep 17 00:00:00 2001 From: Petr Viktorin <[email protected]> Date: Mon, 29 Jun 2026 17:41:26 +0200 Subject: [PATCH] gh-151987: Pass filter_function to TarFile._extract_one() during .extract() (GH-151988) (cherry picked from commit 7ccdbaba2c54250a70d7f25632152df7655a5e0a) Co-authored-by: Petr Viktorin <[email protected]> Co-authored-by: Seth Michael Larson <[email protected]> --- Lib/tarfile.py | 3 Lib/test/test_tarfile.py | 92 ++++++++++ Misc/NEWS.d/next/Security/2026-06-23-14-19-30.gh-issue-151987.8mNIMf.rst | 2 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Security/2026-06-23-14-19-30.gh-issue-151987.8mNIMf.rst Index: Python-3.12.13/Lib/tarfile.py =================================================================== --- Python-3.12.13.orig/Lib/tarfile.py 2026-07-31 15:09:56.986590323 +0200 +++ Python-3.12.13/Lib/tarfile.py 2026-07-31 15:09:57.066061566 +0200 @@ -2401,7 +2401,8 @@ tarinfo, unfiltered = self._get_extract_tarinfo( member, filter_function, path) if tarinfo is not None: - self._extract_one(tarinfo, path, set_attrs, numeric_owner) + self._extract_one(tarinfo, path, set_attrs, numeric_owner, + filter_function=filter_function) def _get_extract_tarinfo(self, member, filter_function, path): """Get (filtered, unfiltered) TarInfos from *member* Index: Python-3.12.13/Lib/test/test_tarfile.py =================================================================== --- Python-3.12.13.orig/Lib/test/test_tarfile.py 2026-07-31 15:09:56.987688633 +0200 +++ Python-3.12.13/Lib/test/test_tarfile.py 2026-07-31 15:09:57.067008080 +0200 @@ -4178,6 +4178,98 @@ st_mode = cc.outerdir.stat().st_mode self.assertNotEqual(st_mode & 0o777, 0o777) + @symlink_test + @unittest.skipUnless(hasattr(os, 'chown'), "missing os.chown") + @unittest.skipUnless(hasattr(os, 'lchown'), "missing os.lchown") + @unittest.skipUnless(hasattr(os, 'geteuid'), "missing os.geteuid") + @support.subTests('link_type', (tarfile.SYMTYPE, tarfile.LNKTYPE)) + def test_chown_links_on_extract(self, link_type): + with ArchiveMaker() as arc: + arc.add("test.txt", + uid=1337, gid=1337, uname="", gname="", mode='-rwxr-xr-x') + arc.add("link", + type=link_type, + linkname='test.txt', + uid=1337, gid=1337, uname="", gname="", mode='-rwxr-xr-x') + + with ( + os_helper.temp_dir() as tmpdir, + arc.open() as tar, + unittest.mock.patch("os.chown") as mock_chown, + unittest.mock.patch("os.lchown") as mock_lchown, + unittest.mock.patch("os.geteuid") as mock_geteuid, + ): + # Set UID to 0 so chown() is attempted. + mock_geteuid.return_value = 0 + tar.extract("link", path=tmpdir, filter='data') + extract_path = os.path.join(tmpdir, "link") + + if link_type == tarfile.SYMTYPE: + mock_chown.assert_not_called() + mock_lchown.assert_called_once_with(extract_path, -1, -1) + else: + mock_chown.assert_has_calls([ + unittest.mock.call(extract_path, -1, -1), + unittest.mock.call(extract_path, -1, -1) + ]) + mock_lchown.assert_not_called() + + @symlink_test + @unittest.skipUnless(hasattr(os, 'chown'), "missing os.chown") + @unittest.skipUnless(hasattr(os, 'lchown'), "missing os.lchown") + @unittest.skipUnless(hasattr(os, 'geteuid'), "missing os.geteuid") + @support.subTests('link_type', (tarfile.SYMTYPE, tarfile.LNKTYPE)) + def test_chown_links_on_extractall(self, link_type): + with ArchiveMaker() as arc: + arc.add("test.txt", + uid=1337, gid=1337, uname="", gname="", mode='-rwxr-xr-x') + arc.add("link", + type=link_type, + linkname='test.txt', + uid=1337, gid=1337, uname="", gname="", mode='-rwxr-xr-x') + + with ( + os_helper.temp_dir() as tmpdir, + arc.open() as tar, + unittest.mock.patch("os.chown") as mock_chown, + unittest.mock.patch("os.lchown") as mock_lchown, + unittest.mock.patch("os.geteuid") as mock_geteuid, + ): + # Set UID to 0 so chown() is attempted. + mock_geteuid.return_value = 0 + tar.extractall(path=tmpdir, filter='data') + extract_link_path = os.path.join(tmpdir, "link") + extract_file_path = os.path.join(tmpdir, "test.txt") + + if link_type == tarfile.SYMTYPE: + mock_chown.assert_called_once_with(extract_file_path, -1, -1) + mock_lchown.assert_called_once_with(extract_link_path, -1, -1) + else: + mock_chown.assert_has_calls([ + unittest.mock.call(extract_file_path, -1, -1), + unittest.mock.call(extract_link_path, -1, -1) + ]) + mock_lchown.assert_not_called() + + def test_extract_filters_target(self): + # Test that when extract() falls back to extracting (rather than + # linking) a hardlink target, it filters the target. + with ArchiveMaker() as arc: + arc.add("target") + arc.add("link", hardlink_to="target") + def testing_filter(member, path): + if member.name == 'target': + # target: set read-only + return member.replace(mode=stat.S_IRUSR) + # link: don't overwrite the mode + return member.replace(mode=None) + tempdir = pathlib.Path(TEMPDIR) / 'extract' + with os_helper.temp_dir(tempdir), arc.open() as tar: + tar.extract("link", path=tempdir, filter=testing_filter) + path = tempdir / 'link' + if os_helper.can_chmod(): + self.assertFalse(path.stat().st_mode & stat.S_IWUSR) + def test_link_fallback_normalizes(self): # Make sure hardlink fallbacks work for non-normalized paths for all # filters Index: Python-3.12.13/Misc/NEWS.d/next/Security/2026-06-23-14-19-30.gh-issue-151987.8mNIMf.rst =================================================================== --- /dev/null 1970-01-01 00:00:00.000000000 +0000 +++ Python-3.12.13/Misc/NEWS.d/next/Security/2026-06-23-14-19-30.gh-issue-151987.8mNIMf.rst 2026-07-31 15:09:57.067631004 +0200 @@ -0,0 +1,2 @@ +The :meth:`tarfile.TarFile.extract` method now applies the given filter when +it extracts a link target from the archive as a fallback. ++++++ CVE-2026-7210-pyexpat-entropy-hash-flooding.patch ++++++ >From 47c9d32583821997bb6bf11e6293a4e2d0d590c2 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych <[email protected]> Date: Tue, 9 Jun 2026 15:07:36 +0100 Subject: [PATCH] [3.13] gh-149018: Use `XML_SetHashSalt16Bytes` in `pyexpat`/`_elementtree` when possible (GH-149023) (cherry picked from commit eeea765cb9d8f1fc3d8918b272ac3c477983f27a) Co-authored-by: Stan Ulbrych <[email protected]> (cherry picked from commit 24b8f12544468e4cedf5bfbe25442fcd495391e4) --- Include/pyexpat.h | 3 +++ Include/pyhash.h | 7 ++++--- Misc/NEWS.d/next/Security/2026-04-26-19-30-45.gh-issue-149018.a9SqWb.rst | 3 +++ Modules/_elementtree.c | 8 ++++++-- Modules/pyexpat.c | 10 +++++++++- 5 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Security/2026-04-26-19-30-45.gh-issue-149018.a9SqWb.rst Index: Python-3.12.13/Include/pyexpat.h =================================================================== --- Python-3.12.13.orig/Include/pyexpat.h 2026-03-03 13:39:30.000000000 +0100 +++ Python-3.12.13/Include/pyexpat.h 2026-07-05 01:12:50.817822904 +0200 @@ -57,6 +57,9 @@ XML_Parser parser, unsigned long long activationThresholdBytes); XML_Bool (*SetAllocTrackerMaximumAmplification)( XML_Parser parser, float maxAmplificationFactor); + /* might be NULL for expat < 2.8.0 */ + XML_Bool (*SetHashSalt16Bytes)( + XML_Parser parser, const uint8_t entropy[16]); /* always add new stuff to the end! */ }; Index: Python-3.12.13/Include/pyhash.h =================================================================== --- Python-3.12.13.orig/Include/pyhash.h 2026-03-03 13:39:30.000000000 +0100 +++ Python-3.12.13/Include/pyhash.h 2026-07-05 01:46:22.091756242 +0200 @@ -35,7 +35,7 @@ /* hash secret * * memory layout on 64 bit systems - * cccccccc cccccccc cccccccc uc -- unsigned char[24] + * cccccccc cccccccc cccccccc cccccccc cccccccc uc -- unsigned char[40] * pppppppp ssssssss ........ fnv -- two Py_hash_t * k0k0k0k0 k1k1k1k1 ........ siphash -- two uint64_t * ........ ........ ssssssss djbx33a -- 16 bytes padding + one Py_hash_t @@ -53,8 +53,8 @@ */ #ifndef Py_LIMITED_API typedef union { - /* ensure 24 bytes */ - unsigned char uc[24]; + /* ensure 40 bytes */ + unsigned char uc[40]; /* two Py_hash_t for FNV */ struct { Py_hash_t prefix; @@ -73,6 +73,7 @@ struct { unsigned char padding[16]; Py_hash_t hashsalt; + uint8_t hashsalt16[16]; } expat; } _Py_HashSecret_t; PyAPI_DATA(_Py_HashSecret_t) _Py_HashSecret; Index: Python-3.12.13/Misc/NEWS.d/next/Security/2026-04-26-19-30-45.gh-issue-149018.a9SqWb.rst =================================================================== --- /dev/null 1970-01-01 00:00:00.000000000 +0000 +++ Python-3.12.13/Misc/NEWS.d/next/Security/2026-04-26-19-30-45.gh-issue-149018.a9SqWb.rst 2026-07-05 01:12:50.819638091 +0200 @@ -0,0 +1,3 @@ +Improved protection against XML hash-flooding attacks in +:mod:`xml.parsers.expat` and :mod:`xml.etree.ElementTree` when Python is +compiled with libExpat 2.8.0 or later. Index: Python-3.12.13/Modules/_elementtree.c =================================================================== --- Python-3.12.13.orig/Modules/_elementtree.c 2026-03-03 13:39:30.000000000 +0100 +++ Python-3.12.13/Modules/_elementtree.c 2026-07-05 01:12:50.820729257 +0200 @@ -3657,8 +3657,12 @@ PyErr_NoMemory(); return -1; } - /* expat < 2.1.0 has no XML_SetHashSalt() */ - if (EXPAT(st, SetHashSalt) != NULL) { + // Prefer 16-byte entropy, only expat >= 2.8.0. See gh-149018 + if (EXPAT(st, SetHashSalt16Bytes) != NULL) { + EXPAT(st, SetHashSalt16Bytes)(self->parser, + _Py_HashSecret.expat.hashsalt16); + } + else if (EXPAT(st, SetHashSalt) != NULL) { EXPAT(st, SetHashSalt)(self->parser, (unsigned long)_Py_HashSecret.expat.hashsalt); } Index: Python-3.12.13/Modules/pyexpat.c =================================================================== --- Python-3.12.13.orig/Modules/pyexpat.c 2026-07-05 01:12:50.593816872 +0200 +++ Python-3.12.13/Modules/pyexpat.c 2026-07-05 01:12:50.821624893 +0200 @@ -1395,7 +1395,10 @@ Py_DECREF(self); return NULL; } -#if XML_COMBINED_VERSION >= 20100 +#if XML_COMBINED_VERSION >= 20800 + /* This feature was added upstream in libexpat 2.8.0. */ + XML_SetHashSalt16Bytes(self->itself, _Py_HashSecret.expat.hashsalt16); +#elif XML_COMBINED_VERSION >= 20100 /* This feature was added upstream in libexpat 2.1.0. */ XML_SetHashSalt(self->itself, (unsigned long)_Py_HashSecret.expat.hashsalt); @@ -2264,6 +2267,11 @@ #else capi->SetHashSalt = NULL; #endif +#if XML_COMBINED_VERSION >= 20800 + capi->SetHashSalt16Bytes = XML_SetHashSalt16Bytes; +#else + capi->SetHashSalt16Bytes = NULL; +#endif #if XML_COMBINED_VERSION >= 20600 capi->SetReparseDeferralEnabled = XML_SetReparseDeferralEnabled; #else ++++++ CVE-2026-7774-tarfile-data_filter-symlink.patch ++++++ >From 6df9892a802c124c4223e17c4a05ea7054166ac7 Mon Sep 17 00:00:00 2001 From: Petr Viktorin <[email protected]> Date: Fri, 8 May 2026 14:16:06 +0200 Subject: [PATCH 1/2] gh-149486: tarfile.data_filter: validate written link target (GH-149487) The data filter rewrote linknames with normpath() but ran the containment check against the un-normalised value, and computed a symlink's directory before stripping trailing slashes. Both let a crafted archive create links pointing outside the destination. Also reject link members that resolve to the destination directory itself, which could otherwise replace it with a symlink and redirect all subsequent members. (Patch by Greg; Petr's just reviewing & merging.) (cherry picked from commit 578411982c16f753f4893532510099ef665117da) Co-authored-by: Petr Viktorin <[email protected]> Co-authored-by: Gregory P. Smith <[email protected]> --- Lib/tarfile.py | 16 - Lib/test/test_tarfile.py | 151 +++++++--- Misc/NEWS.d/next/Security/2026-05-03-21-00-00.gh-issue-149486.tarflt.rst | 5 3 files changed, 133 insertions(+), 39 deletions(-) create mode 100644 Misc/NEWS.d/next/Security/2026-05-03-21-00-00.gh-issue-149486.tarflt.rst Index: Python-3.12.13/Lib/tarfile.py =================================================================== --- Python-3.12.13.orig/Lib/tarfile.py 2026-07-31 15:47:33.973188784 +0200 +++ Python-3.12.13/Lib/tarfile.py 2026-07-31 15:47:34.000601885 +0200 @@ -817,16 +817,22 @@ if member.islnk() or member.issym(): if os.path.isabs(member.linkname): raise AbsoluteLinkError(member) + # A link member that resolves to the destination directory itself + # would replace it with a (sym)link, redirecting the destination + # for all subsequent members. + if target_path == dest_path: + raise OutsideDestinationError(member, target_path) normalized = os.path.normpath(member.linkname) if normalized != member.linkname: new_attrs['linkname'] = normalized if member.issym(): - target_path = os.path.join(dest_path, - os.path.dirname(name), - member.linkname) + # The symlink is created at `name` with trailing separators + # stripped, so its target is relative to the directory + # containing that path. + link_dir = os.path.dirname(name.rstrip('/' + os.sep)) + target_path = os.path.join(dest_path, link_dir, normalized) else: - target_path = os.path.join(dest_path, - member.linkname) + target_path = os.path.join(dest_path, normalized) target_path = os.path.realpath(target_path, strict=os.path.ALLOW_MISSING) if os.path.commonpath([target_path, dest_path]) != dest_path: Index: Python-3.12.13/Lib/test/test_tarfile.py =================================================================== --- Python-3.12.13.orig/Lib/test/test_tarfile.py 2026-07-31 15:47:33.974021896 +0200 +++ Python-3.12.13/Lib/test/test_tarfile.py 2026-07-31 15:47:34.003155983 +0200 @@ -3549,6 +3549,39 @@ # The destination for the extraction, within `outerdir` destdir = outerdir / 'dest' + @classmethod + def setUpClass(cls): + # Posix and Windows have different pathname resolution: + # either symlink or a '..' component resolve first. + # Let's see which we are on. + if os_helper.can_symlink(): + testpath = os.path.join(TEMPDIR, 'resolution_test') + os.mkdir(testpath) + + # testpath/current links to `.` which is all of: + # - `testpath` + # - `testpath/current` + # - `testpath/current/current` + # - etc. + os.symlink('.', os.path.join(testpath, 'current')) + + # we'll test where `testpath/current/../file` ends up + with open(os.path.join(testpath, 'current', '..', 'file'), 'w'): + pass + + if os.path.exists(os.path.join(testpath, 'file')): + # Windows collapses 'current\..' to '.' first, leaving + # 'testpath\file' + cls.dotdot_resolves_early = True + elif os.path.exists(os.path.join(testpath, '..', 'file')): + # Posix resolves 'current' to '.' first, leaving + # 'testpath/../file' + cls.dotdot_resolves_early = False + else: + raise AssertionError('Could not determine link resolution') + else: + cls.dotdot_resolves_early = False + @contextmanager def check_context(self, tar, filter, *, check_flag=True): """Extracts `tar` to `self.destdir` and allows checking the result @@ -3720,10 +3753,19 @@ + "which is outside the destination") with self.check_context(arc.open(), 'data'): - self.expect_exception( - tarfile.LinkOutsideDestinationError, - """'parent' would link to ['"].*outerdir['"], """ - + "which is outside the destination") + if self.dotdot_resolves_early: + # 'current/../..' normalises to '..', which is rejected. + self.expect_exception( + tarfile.LinkOutsideDestinationError, + """'parent' would link to ['"].*outerdir['"], """ + + "which is outside the destination") + else: + # 'current/..' normalises to '.'; the rewritten link is + # created and 'parent/evil' lands harmlessly inside the + # destination. + self.expect_file('current', symlink_to='.') + self.expect_file('parent', symlink_to='.') + self.expect_file('evil') else: # No symlink support. The symlinks are ignored. @@ -3813,35 +3855,6 @@ # Test interplaying symlinks # Inspired by 'dirsymlink2b' in jwilk/traversal-archives - # Posix and Windows have different pathname resolution: - # either symlink or a '..' component resolve first. - # Let's see which we are on. - if os_helper.can_symlink(): - testpath = os.path.join(TEMPDIR, 'resolution_test') - os.mkdir(testpath) - - # testpath/current links to `.` which is all of: - # - `testpath` - # - `testpath/current` - # - `testpath/current/current` - # - etc. - os.symlink('.', os.path.join(testpath, 'current')) - - # we'll test where `testpath/current/../file` ends up - with open(os.path.join(testpath, 'current', '..', 'file'), 'w'): - pass - - if os.path.exists(os.path.join(testpath, 'file')): - # Windows collapses 'current\..' to '.' first, leaving - # 'testpath\file' - dotdot_resolves_early = True - elif os.path.exists(os.path.join(testpath, '..', 'file')): - # Posix resolves 'current' to '.' first, leaving - # 'testpath/../file' - dotdot_resolves_early = False - else: - raise AssertionError('Could not determine link resolution') - with ArchiveMaker() as arc: # `current` links to `.` which is both the destination directory @@ -3877,7 +3890,7 @@ with self.check_context(arc.open(), 'data'): if os_helper.can_symlink(): - if dotdot_resolves_early: + if self.dotdot_resolves_early: # Fail when extracting a file outside destination self.expect_exception( tarfile.OutsideDestinationError, @@ -3998,6 +4011,76 @@ + "destination") @symlink_test + @os_helper.skip_unless_symlink + def test_normpath_realpath_mismatch(self): + # The link-target check must validate the value that will actually + # be written to disk (the normalised linkname), not the original. + # Here 'a' is a symlink to a deep nonexistent path, so realpath() + # of 'a/../../...' stays inside the destination while normpath() + # collapses 'a/..' lexically and escapes. + depth = len(self.destdir.parts) + 5 + deep = '/'.join(f'p{i}' for i in range(depth)) + sneaky = 'a/' + '../' * depth + 'flag' + for kind in 'symlink_to', 'hardlink_to': + with self.subTest(kind): + with ArchiveMaker() as arc: + arc.add('a', symlink_to=deep) + arc.add('escape', **{kind: sneaky}) + with self.check_context(arc.open(), 'data'): + self.expect_exception( + tarfile.LinkOutsideDestinationError) + + @symlink_test + @os_helper.skip_unless_symlink + def test_symlink_trailing_slash(self): + # A trailing slash on a symlink member's name must not cause the + # link target to be resolved relative to the wrong directory. + with ArchiveMaker() as arc: + t = tarfile.TarInfo('x/') + t.type = tarfile.SYMTYPE + t.linkname = '..' + arc.tar_w.addfile(t) + arc.add('x/escaped', content='hi') + + with self.check_context(arc.open(), 'data'): + self.expect_exception(tarfile.LinkOutsideDestinationError) + + @symlink_test + @os_helper.skip_unless_symlink + def test_link_at_destination(self): + # A link member whose name resolves to the destination directory + # itself must be rejected: otherwise the destination is replaced + # by a symlink and later members can be redirected through it. + for name in '', '.', './': + with ArchiveMaker() as arc: + t = tarfile.TarInfo(name) + t.type = tarfile.SYMTYPE + t.linkname = '.' + arc.tar_w.addfile(t) + + with self.check_context(arc.open(), 'data'): + self.expect_exception(tarfile.OutsideDestinationError) + + @symlink_test + @os_helper.skip_unless_symlink + def test_empty_name_symlink_chain(self): + # Regression test for a chain of empty-named symlinks that + # incrementally redirects the destination outwards. + with ArchiveMaker() as arc: + for name, target in [('', ''), ('a/', '..'), + ('', 'dummy'), ('', 'a'), + ('b/', '..'), + ('', 'dummy'), ('', 'a/b')]: + t = tarfile.TarInfo(name) + t.type = tarfile.SYMTYPE + t.linkname = target + arc.tar_w.addfile(t) + arc.add('escaped', content='hi') + + with self.check_context(arc.open(), 'data'): + self.expect_exception(tarfile.FilterError) + + @symlink_test def test_deep_symlink(self): # Test that symlinks and hardlinks inside a directory # point to the correct file (`target` of size 3). Index: Python-3.12.13/Misc/NEWS.d/next/Security/2026-05-03-21-00-00.gh-issue-149486.tarflt.rst =================================================================== --- /dev/null 1970-01-01 00:00:00.000000000 +0000 +++ Python-3.12.13/Misc/NEWS.d/next/Security/2026-05-03-21-00-00.gh-issue-149486.tarflt.rst 2026-07-31 15:47:34.002049787 +0200 @@ -0,0 +1,5 @@ +:func:`tarfile.data_filter` now validates link targets using the same +normalised value that is written to disk, strips trailing separators from +the member name when resolving a symlink's directory, and rejects link +members that would replace the destination directory itself. This closes +several path-traversal bypasses of the ``data`` extraction filter. ++++++ CVE-2026-8328-ftplib-no-trust-PASV-resp.patch ++++++ >From b026be60f7e023719a4d8de89ae3c3248b7c5d40 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" <[email protected]> Date: Wed, 13 May 2026 10:33:43 -0700 Subject: [PATCH] gh-87451: Apply CVE-2021-4189 PASV fix to ftplib.ftpcp() (GH-149648) ftpcp() called parse227() directly and passed the source server's self-reported PASV IPv4 address to the target server's PORT command, bypassing the CVE-2021-4189 fix that was applied only to FTP.makepasv(). A malicious source FTP server could use this to redirect the target server's data connection to an arbitrary host:port (SSRF). ftpcp() now uses the source server's actual peer address, honoring the existing trust_server_pasv_ipv4_address opt-out, the same as makepasv(). Thanks to Qi Ding at Aurascape AI for the report. (GHSA-w8c5-q2xf-gf7c) (cherry picked from commit eac4fe3b2c77693790a5ef7dfab127c1fee81bf9) Co-authored-by: Gregory P. Smith <[email protected]> --- Lib/ftplib.py | 11 ++- Lib/test/test_ftplib.py | 36 +++++++++- Misc/NEWS.d/next/Security/2026-05-10-18-05-32.gh-issue-87451.XkKB6M.rst | 6 + 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Security/2026-05-10-18-05-32.gh-issue-87451.XkKB6M.rst Index: Python-3.12.13/Lib/ftplib.py =================================================================== --- Python-3.12.13.orig/Lib/ftplib.py 2026-07-03 00:50:14.031086570 +0200 +++ Python-3.12.13/Lib/ftplib.py 2026-07-03 00:50:20.046470436 +0200 @@ -883,7 +883,16 @@ type = 'TYPE ' + type source.voidcmd(type) target.voidcmd(type) - sourcehost, sourceport = parse227(source.sendcmd('PASV')) + # Don't trust the IPv4 address the source server advertises in its PASV + # reply: a malicious source could otherwise point the target's data + # connection at an arbitrary host (SSRF). A caller that needs the old + # behavior can set trust_server_pasv_ipv4_address on the source FTP + # object. See FTP.makepasv(), which applies the same rule. + untrusted_host, sourceport = parse227(source.sendcmd('PASV')) + if source.trust_server_pasv_ipv4_address: + sourcehost = untrusted_host + else: + sourcehost = source.sock.getpeername()[0] target.sendport(sourcehost, sourceport) # RFC 959: the user must "listen" [...] BEFORE sending the # transfer request. Index: Python-3.12.13/Lib/test/test_ftplib.py =================================================================== --- Python-3.12.13.orig/Lib/test/test_ftplib.py 2026-07-03 00:50:15.720789920 +0200 +++ Python-3.12.13/Lib/test/test_ftplib.py 2026-07-03 00:50:20.046728874 +0200 @@ -16,7 +16,7 @@ except ImportError: ssl = None -from unittest import TestCase, skipUnless +from unittest import mock, TestCase, skipUnless from test import support from test.support import threading_helper from test.support import socket_helper @@ -1142,6 +1142,40 @@ ftp.close() +class TestFtpcpSecurity(TestCase): + """ftpcp() must not trust the host a source server advertises in PASV. + + A malicious source server can otherwise redirect the target server's + data connection to an arbitrary host:port (SSRF), so ftpcp() uses the + source server's actual peer address instead, the same as FTP.makepasv(). + """ + + def _make_pair(self, *, advertised_host, real_host, trust=False): + source = mock.Mock(spec=ftplib.FTP) + source.trust_server_pasv_ipv4_address = trust + source.sock.getpeername.return_value = (real_host, 21) + # PASV replies give the host as comma-separated octets, not dotted. + advertised = advertised_host.replace('.', ',') + source.sendcmd.side_effect = lambda cmd: ( + f'227 Entering Passive Mode ({advertised},1,2).' + if cmd == 'PASV' else '150 ok') + target = mock.Mock(spec=ftplib.FTP) + target.sendcmd.return_value = '150 ok' + return source, target + + def test_ftpcp_ignores_untrusted_pasv_host(self): + source, target = self._make_pair(advertised_host='10.0.0.5', + real_host='198.51.100.7') + ftplib.ftpcp(source, 'a', target, 'b') + target.sendport.assert_called_once_with('198.51.100.7', 258) + + def test_ftpcp_trust_server_pasv_ipv4_address(self): + source, target = self._make_pair(advertised_host='10.0.0.5', + real_host='198.51.100.7', trust=True) + ftplib.ftpcp(source, 'a', target, 'b') + target.sendport.assert_called_once_with('10.0.0.5', 258) + + class MiscTestCase(TestCase): def test__all__(self): not_exported = { Index: Python-3.12.13/Misc/NEWS.d/next/Security/2026-05-10-18-05-32.gh-issue-87451.XkKB6M.rst =================================================================== --- /dev/null 1970-01-01 00:00:00.000000000 +0000 +++ Python-3.12.13/Misc/NEWS.d/next/Security/2026-05-10-18-05-32.gh-issue-87451.XkKB6M.rst 2026-07-03 00:50:20.046898824 +0200 @@ -0,0 +1,6 @@ +The :mod:`ftplib` module's undocumented ``ftpcp`` function no longer trusts +the IPv4 address value returned from the source server in response to the +``PASV`` command by default, completing the fix for CVE-2021-4189. As with +:class:`ftplib.FTP`, the former behavior can be re-enabled by setting the +``trust_server_pasv_ipv4_address`` attribute on the source :class:`ftplib.FTP` +instance to ``True``. Thanks to Qi Deng at Aurascape AI for the report. ++++++ _scmsync.obsinfo ++++++ --- /var/tmp/diff_new_pack.53gIG0/_old 2026-08-01 18:28:59.559081862 +0200 +++ /var/tmp/diff_new_pack.53gIG0/_new 2026-08-01 18:28:59.563081998 +0200 @@ -1,6 +1,6 @@ -mtime: 1782731285 -commit: 73df3475a766625be6b1895e2c6390867a4cc128f3291020fa0fa0a87be4fe99 +mtime: 1785506276 +commit: dc5782910c671851c0c6988f459e9ac9e73a1c74881059612ce050729d67cda3 url: https://src.opensuse.org/python-interpreters/python312 -revision: 73df3475a766625be6b1895e2c6390867a4cc128f3291020fa0fa0a87be4fe99 +revision: dc5782910c671851c0c6988f459e9ac9e73a1c74881059612ce050729d67cda3 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-07-31 15:57:56.000000000 +0200 @@ -0,0 +1,6 @@ +_build.* +*.obscpio +*.osc +.osc +.pbuild +python312-3.12.*-build ++++++ support-sphinx-9.patch ++++++ >From e7eedd63aa09cd59f13c017084a91ad3591b0e16 Mon Sep 17 00:00:00 2001 From: Adam Turner <[email protected]> Date: Sat, 29 Nov 2025 00:49:46 +0000 Subject: [PATCH] Prepare for Sphinx 9 --- Doc/howto/urllib2.rst | 2 +- Doc/library/socket.rst | 7 ++----- Doc/tools/extensions/glossary_search.py | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) Index: Python-3.12.13/Doc/howto/urllib2.rst =================================================================== --- Python-3.12.13.orig/Doc/howto/urllib2.rst +++ Python-3.12.13/Doc/howto/urllib2.rst @@ -15,7 +15,7 @@ Introduction You may also find useful the following article on fetching web resources with Python: - * `Basic Authentication <https://web.archive.org/web/20201215133350/http://www.voidspace.org.uk/python/articles/authentication.shtml>`_ + * `Basic Authentication <https://web.archive.org/web/20201215133350/http://www.voidspace.org.uk/python/articles/authentication.shtml>`__ A tutorial on *Basic Authentication*, with examples in Python. Index: Python-3.12.13/Doc/library/socket.rst =================================================================== --- Python-3.12.13.orig/Doc/library/socket.rst +++ Python-3.12.13/Doc/library/socket.rst @@ -1961,11 +1961,8 @@ to sockets. :attr:`socket.type`. -.. method:: socket.setsockopt(level, optname, value: int) -.. method:: socket.setsockopt(level, optname, value: buffer) - :noindex: -.. method:: socket.setsockopt(level, optname, None, optlen: int) - :noindex: +.. method:: socket.setsockopt(level, optname, value: int | Buffer) + socket.setsockopt(level, optname, None, optlen: int) .. index:: pair: module; struct Index: Python-3.12.13/Doc/tools/extensions/glossary_search.py =================================================================== --- Python-3.12.13.orig/Doc/tools/extensions/glossary_search.py +++ Python-3.12.13/Doc/tools/extensions/glossary_search.py @@ -42,7 +42,7 @@ def process_glossary_nodes( rendered = app.builder.render_partial(definition) terms[term.lower()] = { 'title': term, - 'body': rendered['html_body'], + 'body': rendered['fragment'], }
