From b00943d873edc8a44c106c71c09261712c26489d Mon Sep 17 00:00:00 2001 From: "SUSE Observability AI (POC)" Date: Fri, 18 Sep 2026 09:39:43 +0000 Subject: [PATCH 1/6] fix(python): backport credential, tar extraction and POP3 security fixes --- .github/workflows/build-deb.yml | 5 + ...-2026-15806-urllib-credential-scheme.patch | 195 ++++++++++++++++++ .../0004-CVE-2026-19672-tarfile-path.patch | 98 +++++++++ .../0005-CVE-2025-15367-poplib-commands.patch | 62 ++++++ deps/cpython/SECURITY_PATCHES.md | 38 ++++ deps/cpython/cpython.MODULE.bazel | 3 + ...python-security-backports-35310940870.yaml | 8 + scripts/test_embedded_python_security.py | 73 +++++++ 8 files changed, 482 insertions(+) create mode 100644 deps/cpython/0003-CVE-2026-15806-urllib-credential-scheme.patch create mode 100644 deps/cpython/0004-CVE-2026-19672-tarfile-path.patch create mode 100644 deps/cpython/0005-CVE-2025-15367-poplib-commands.patch create mode 100644 deps/cpython/SECURITY_PATCHES.md create mode 100644 releasenotes/notes/python-security-backports-35310940870.yaml create mode 100644 scripts/test_embedded_python_security.py diff --git a/.github/workflows/build-deb.yml b/.github/workflows/build-deb.yml index 6cea8a3f95ed..062ca03a630b 100644 --- a/.github/workflows/build-deb.yml +++ b/.github/workflows/build-deb.yml @@ -320,6 +320,11 @@ jobs: set -eo pipefail docker run --rm --entrypoint /opt/stackstate-agent/bin/agent/agent "${LOCAL_IMAGE}" version + - name: Verify embedded Python security fixes + run: | + set -eo pipefail + docker run --rm -i --entrypoint /opt/stackstate-agent/embedded/bin/python3 "${LOCAL_IMAGE}" - < scripts/test_embedded_python_security.py + - name: Scan agent image, report-only (Trivy and Grype vulnerabilities, VEX-aware, plus Trivy secrets) uses: StackVista/image-pipeline/.github/actions/scan-image@ab8ac3d608530ee0a295483c973d720230174348 with: diff --git a/deps/cpython/0003-CVE-2026-15806-urllib-credential-scheme.patch b/deps/cpython/0003-CVE-2026-15806-urllib-credential-scheme.patch new file mode 100644 index 000000000000..1f3d7daf6807 --- /dev/null +++ b/deps/cpython/0003-CVE-2026-15806-urllib-credential-scheme.patch @@ -0,0 +1,195 @@ +From a2773a34183b7d94a243bb98fd658926cc5348ce Mon Sep 17 00:00:00 2001 +From: "Miss Islington (bot)" + <31488909+miss-islington@users.noreply.github.com> +Date: Tue, 18 Aug 2026 09:25:56 +0200 +Subject: [PATCH] [3.13] gh-155694: Scope HTTPPasswordMgr credentials by URL + scheme (GH-155696) (#155970) +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +gh-155694: Scope HTTPPasswordMgr credentials by URL scheme (GH-155696) + +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 +--- + Doc/library/urllib.request.rst | 10 +++- + Lib/test/test_urllib2.py | 56 +++++++++++++++++++ + Lib/urllib/request.py | 25 +++++++-- + ...-07-31-16-20-17.gh-issue-155694.SsxlKG.rst | 4 ++ + 4 files changed, 87 insertions(+), 8 deletions(-) + create mode 100644 Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst + +diff --git a/Doc/library/urllib.request.rst b/Doc/library/urllib.request.rst +index 4107017021f5abf..3d6199b592ca62b 100644 +--- a/Doc/library/urllib.request.rst ++++ b/Doc/library/urllib.request.rst +@@ -943,8 +943,14 @@ These methods are available on :class:`HTTPPasswordMgr` and + + *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) +diff --git a/Lib/test/test_urllib2.py b/Lib/test/test_urllib2.py +index d94021b31b19ff3..229da626bb78300 100644 +--- a/Lib/test/test_urllib2.py ++++ b/Lib/test/test_urllib2.py +@@ -273,6 +273,50 @@ def test_password_manager_default_port(self): + 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 = [] +@@ -1795,6 +1839,18 @@ def test_basic_prior_auth_auto_send(self): + # 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 + +diff --git a/Lib/urllib/request.py b/Lib/urllib/request.py +index 42147f31d968c77..37af2e3a580da5d 100644 +--- a/Lib/urllib/request.py ++++ b/Lib/urllib/request.py +@@ -815,16 +815,17 @@ def add_password(self, realm, uri, user, passwd): + 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 + +@@ -851,6 +852,17 @@ def reduce_uri(self, uri, default_port=True): + 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 + +@@ -896,14 +908,15 @@ def update_authenticated(self, uri, is_authenticated=False): + + 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] + + +diff --git a/Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst b/Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst +new file mode 100644 +index 000000000000000..dbc2119640702c9 +--- /dev/null ++++ b/Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst +@@ -0,0 +1,4 @@ ++Fix :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. diff --git a/deps/cpython/0004-CVE-2026-19672-tarfile-path.patch b/deps/cpython/0004-CVE-2026-19672-tarfile-path.patch new file mode 100644 index 000000000000..07250a72123a --- /dev/null +++ b/deps/cpython/0004-CVE-2026-19672-tarfile-path.patch @@ -0,0 +1,98 @@ +From c7979f3a819011a3222bd16e671264b1e34282cb Mon Sep 17 00:00:00 2001 +From: "Miss Islington (bot)" + <31488909+miss-islington@users.noreply.github.com> +Date: Wed, 19 Aug 2026 15:33:25 +0200 +Subject: [PATCH] [3.13] gh-155999: `tarfile`: handle a member that leaves the + destination but comes back (GH-156000) (#156042) + +(cherry picked from commit 97688346ada2df3e5b9c279348862c3d64ab0823) + +Co-authored-by: Stan Ulbrych +--- + Doc/library/tarfile.rst | 8 ++++++++ + Lib/tarfile.py | 7 +++++++ + Lib/test/test_tarfile.py | 14 ++++++++++++++ + .../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 + +diff --git a/Doc/library/tarfile.rst b/Doc/library/tarfile.rst +index c820e3d159b9a5b..d5c5328210cd7e4 100644 +--- a/Doc/library/tarfile.rst ++++ b/Doc/library/tarfile.rst +@@ -1049,6 +1049,10 @@ reused in custom filters: + 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 ` to extract files whose absolute + path (after following symlinks) would end up outside the destination. + This raises :class:`~tarfile.OutsideDestinationError`. +@@ -1057,6 +1061,10 @@ reused in custom filters: + + Return the modified ``TarInfo`` member. + ++ .. versionchanged:: next ++ ++ Filenames containing ``..`` components are now normalized. ++ + .. function:: data_filter(member, path) + + Implements the ``'data'`` filter. +diff --git a/Lib/tarfile.py b/Lib/tarfile.py +index 9da2667abe15da6..3c59eac5d9a0f11 100755 +--- a/Lib/tarfile.py ++++ b/Lib/tarfile.py +@@ -808,6 +808,13 @@ def _get_filtered_attrs(member, dest_path, for_data=True): + # 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: +diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py +index 31e844328b31901..86fe8efac2e3495 100644 +--- a/Lib/test/test_tarfile.py ++++ b/Lib/test/test_tarfile.py +@@ -3948,6 +3948,20 @@ def test_absolute(self): + tarfile.AbsolutePathError, + """['"].*escaped.evil['"] has an absolute path""") + ++ def test_parent_dir_out_and_back(self): ++ # Test a member that leaves the destination and comes back. ++ # The containment check looks at the resolved path, which stays ++ # inside, but the intermediate directories are created from the ++ # name as given, which does not. ++ with ArchiveMaker() as arc: ++ arc.add(f'../escaped.evil/../{self.destdir.name}/sub/file', ++ content='content') ++ ++ for filter in 'tar', 'data': ++ with self.subTest(filter): ++ with self.check_context(arc.open(), filter): ++ self.expect_file('sub/file', content='content') ++ + @symlink_test + def test_parent_symlink(self): + # Test interplaying symlinks +diff --git a/Misc/NEWS.d/next/Security/2026-08-13-13-08-11.gh-issue-155999.Xt4rWq.rst b/Misc/NEWS.d/next/Security/2026-08-13-13-08-11.gh-issue-155999.Xt4rWq.rst +new file mode 100644 +index 000000000000000..59b725e55bbffda +--- /dev/null ++++ b/Misc/NEWS.d/next/Security/2026-08-13-13-08-11.gh-issue-155999.Xt4rWq.rst +@@ -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. diff --git a/deps/cpython/0005-CVE-2025-15367-poplib-commands.patch b/deps/cpython/0005-CVE-2025-15367-poplib-commands.patch new file mode 100644 index 000000000000..88576bfff4a1 --- /dev/null +++ b/deps/cpython/0005-CVE-2025-15367-poplib-commands.patch @@ -0,0 +1,62 @@ +From b234a2b67539f787e191d2ef19a7cbdce32874e7 Mon Sep 17 00:00:00 2001 +From: Seth Michael Larson +Date: Tue, 20 Jan 2026 14:46:32 -0600 +Subject: [PATCH] gh-143923: Reject control characters in POP3 commands + +Backport to 3.13.15: adjust test import context for ExtraAssertions; +production fix and upstream regression are unchanged. + +--- + Lib/poplib.py | 2 ++ + Lib/test/test_poplib.py | 8 ++++++++ + .../2026-01-16-11-43-47.gh-issue-143923.DuytMe.rst | 1 + + 3 files changed, 11 insertions(+) + create mode 100644 Misc/NEWS.d/next/Security/2026-01-16-11-43-47.gh-issue-143923.DuytMe.rst + +diff --git a/Lib/poplib.py b/Lib/poplib.py +index 4469bff44b4c455..b97274c5c32ee63 100644 +--- a/Lib/poplib.py ++++ b/Lib/poplib.py +@@ -122,6 +122,8 @@ def _putline(self, line): + def _putcmd(self, line): + if self._debugging: print('*cmd*', repr(line)) + line = bytes(line, self.encoding) ++ if re.search(b'[\x00-\x1F\x7F]', line): ++ raise ValueError('Control characters not allowed in commands') + self._putline(line) + + +diff --git a/Lib/test/test_poplib.py b/Lib/test/test_poplib.py +index ef2da97f86734a2..18ca7cb556836e6 100644 +--- a/Lib/test/test_poplib.py ++++ b/Lib/test/test_poplib.py +@@ -17,7 +17,8 @@ + from test.support import threading_helper + from test.support import asynchat + from test.support import asyncore ++from test.support import control_characters_c0 + from test.support.testcase import ExtraAssertions + + + test_support.requires_working_socket(module=True) +@@ -395,6 +396,13 @@ def test_quit(self): + self.assertIsNone(self.client.sock) + self.assertIsNone(self.client.file) + ++ def test_control_characters(self): ++ for c0 in control_characters_c0(): ++ with self.assertRaises(ValueError): ++ self.client.user(f'user{c0}') ++ with self.assertRaises(ValueError): ++ self.client.pass_(f'{c0}pass') ++ + @requires_ssl + def test_stls_capa(self): + capa = self.client.capa() +diff --git a/Misc/NEWS.d/next/Security/2026-01-16-11-43-47.gh-issue-143923.DuytMe.rst b/Misc/NEWS.d/next/Security/2026-01-16-11-43-47.gh-issue-143923.DuytMe.rst +new file mode 100644 +index 000000000000000..3cde4df3e0069f7 +--- /dev/null ++++ b/Misc/NEWS.d/next/Security/2026-01-16-11-43-47.gh-issue-143923.DuytMe.rst +@@ -0,0 +1 @@ ++Reject control characters in POP3 commands. diff --git a/deps/cpython/SECURITY_PATCHES.md b/deps/cpython/SECURITY_PATCHES.md new file mode 100644 index 000000000000..3eb074005d8f --- /dev/null +++ b/deps/cpython/SECURITY_PATCHES.md @@ -0,0 +1,38 @@ +# Embedded CPython security patches + +The agent packages CPython 3.13.15. `cpython.MODULE.bazel` applies the following +upstream fixes during the source build on every supported platform. The version +string stays 3.13.15; version-only vulnerability scanners can still report these +CVEs. These patches do not change VEX or exception decisions. + +| CVE | Upstream source | Local adaptation | +| --- | --- | --- | +| CVE-2026-15806 | [3.13 commit a2773a34](https://github.com/python/cpython/commit/a2773a34183b7d94a243bb98fd658926cc5348ce) | None | +| CVE-2026-19672 | [3.13 commit c7979f3a](https://github.com/python/cpython/commit/c7979f3a819011a3222bd16e671264b1e34282cb) | None; test hunk has a line offset | +| CVE-2025-15367 | [commit b234a2b6](https://github.com/python/cpython/commit/b234a2b67539f787e191d2ef19a7cbdce32874e7) | Test import context accounts for 3.13's existing `ExtraAssertions` import; production fix and test logic unchanged | + +Each patch retains upstream regression tests. With a patched CPython source build, +run `./python -m test test_urllib2 test_tarfile test_poplib`. The agent image CI +also runs `scripts/test_embedded_python_security.py` with the actual packaged +interpreter on AMD64 and ARM64. This checks the three security boundaries and +valid operations without external network access. Image vulnerability and secret +scans remain separate steps. Remove a backport only when an upstream release +includes it and the packaged-interpreter tests pass. + +Remaining rows as of 2026-09-18: + +- **CVE-2026-15310:** the [3.13 zipfile backport](https://github.com/python/cpython/pull/156738) + remains open. The original fix also needed a [third-party decompressor + compatibility correction](https://github.com/python/cpython/pull/157180). + Reconsider when the 3.13 backport and applicable compatibility correction are + accepted upstream; the scanner's 3.15.0rc2 lead is not a compatible 3.13 bump. +- **CVE-2026-17084:** retain the existing no-stable-3.13-fix assessment; the known + scanner lead is 3.15.0rc2. Reconsider on a supported 3.13 fix/backport or changed + upstream evidence in [issue 155292](https://github.com/python/cpython/issues/155292). +- **CVE-2026-87910:** retain the existing no-scanner-fix assessment. Reconsider + when [issue 157265](https://github.com/python/cpython/issues/157265) provides a + supported 3.13 correction or scanner data changes. + +The latest upstream 3.13 tag checked was 3.13.15. No runtime upgrade to 3.15, +applicability decision, suppression, or exception renewal is part of this work. +PR511 and GO-2026-5932 remain outside this patch's scope. diff --git a/deps/cpython/cpython.MODULE.bazel b/deps/cpython/cpython.MODULE.bazel index a1f2b2ab80a0..8f3d294646f1 100644 --- a/deps/cpython/cpython.MODULE.bazel +++ b/deps/cpython/cpython.MODULE.bazel @@ -12,6 +12,9 @@ http_archive( patches = [ "//deps/cpython:0001-customize-windows-build-script.patch", "//deps/cpython:0002-Set-the-install-name-to-use-rpath-instead-of-absolut.patch", + "//deps/cpython:0003-CVE-2026-15806-urllib-credential-scheme.patch", + "//deps/cpython:0004-CVE-2026-19672-tarfile-path.patch", + "//deps/cpython:0005-CVE-2025-15367-poplib-commands.patch", ], sha256 = "c28d9d213c09b5b5ab2c29812950e12f746999e099b82894231be954b26baed9", strip_prefix = "Python-{}".format(PYTHON_VERSION), diff --git a/releasenotes/notes/python-security-backports-35310940870.yaml b/releasenotes/notes/python-security-backports-35310940870.yaml new file mode 100644 index 000000000000..e136b48838c5 --- /dev/null +++ b/releasenotes/notes/python-security-backports-35310940870.yaml @@ -0,0 +1,8 @@ +--- +security: + - | + Backport Python fixes for CVE-2026-15806, CVE-2026-19672 and CVE-2025-15367. + HTTP authentication credentials are scoped by URL scheme, tar extraction + filters avoid creating directories outside the destination, and POP3 + commands reject control characters. The embedded Python version remains + 3.13.15 with these source patches applied. diff --git a/scripts/test_embedded_python_security.py b/scripts/test_embedded_python_security.py new file mode 100644 index 000000000000..fca4039fbc56 --- /dev/null +++ b/scripts/test_embedded_python_security.py @@ -0,0 +1,73 @@ +"""Regressions to run with the Python interpreter shipped in the agent image.""" + +import hashlib +import io +import poplib +import sys +import tarfile +import tempfile +import unittest +import urllib.request +from pathlib import Path +from unittest.mock import Mock + + +class EmbeddedPythonSecurityTests(unittest.TestCase): + def test_credentials_are_scoped_by_scheme(self): + for manager in ( + urllib.request.HTTPPasswordMgr, + urllib.request.HTTPPasswordMgrWithDefaultRealm, + urllib.request.HTTPPasswordMgrWithPriorAuth, + ): + with self.subTest(manager=manager.__name__): + passwords = manager() + passwords.add_password(None, "https://example.invalid/", "user", "test-value") + self.assertEqual( + passwords.find_user_password(None, "https://example.invalid/"), + ("user", "test-value"), + ) + self.assertEqual(passwords.find_user_password(None, "http://example.invalid/"), (None, None)) + passwords.add_password(None, "proxy.invalid", "proxy", "test-value") + for scheme in ("http", "https"): + self.assertEqual( + passwords.find_user_password(None, f"{scheme}://proxy.invalid/"), + ("proxy", "test-value"), + ) + + def test_tar_filters_do_not_create_directories_outside_destination(self): + for extraction_filter in ("tar", "data"): + with self.subTest(filter=extraction_filter), tempfile.TemporaryDirectory() as root: + destination = Path(root) / "destination" + destination.mkdir() + archive = io.BytesIO() + content = b"expected content" + with tarfile.open(fileobj=archive, mode="w") as writer: + member = tarfile.TarInfo("../outside/../destination/sub/file") + member.size = len(content) + writer.addfile(member, io.BytesIO(content)) + archive.seek(0) + with tarfile.open(fileobj=archive) as reader: + reader.extractall(destination, filter=extraction_filter) + self.assertEqual((destination / "sub/file").read_bytes(), content) + self.assertFalse((Path(root) / "outside").exists()) + + def test_pop3_rejects_control_characters_before_sending(self): + client = poplib.POP3.__new__(poplib.POP3) + client._debugging = 0 + client.encoding = "utf-8" + client._putline = Mock() + client._putcmd("USER valid-user") + client._putline.assert_called_once_with(b"USER valid-user") + client._putline.reset_mock() + for character in (*range(32), 127): + with self.subTest(character=character), self.assertRaises(ValueError): + client._putcmd(f"USER invalid{chr(character)}value") + client._putline.assert_not_called() + + +if __name__ == "__main__": + print(f"Embedded interpreter: {sys.executable}; version: {sys.version}", flush=True) + for module in (urllib.request, tarfile, poplib): + source = Path(module.__file__) + print(f"{module.__name__}: {source}; sha256={hashlib.sha256(source.read_bytes()).hexdigest()}", flush=True) + unittest.main(verbosity=2) From 1203ae2e8b6587d12c6c5eb979b9c64c34208e2c Mon Sep 17 00:00:00 2001 From: "SUSE Observability AI (POC)" Date: Fri, 18 Sep 2026 09:42:39 +0000 Subject: [PATCH 2/6] fix(python): include supported StringPrep security backport --- ...06-CVE-2026-17084-stringprep-unicode.patch | 930 ++++++++++++++++++ deps/cpython/SECURITY_PATCHES.md | 15 +- deps/cpython/cpython.MODULE.bazel | 1 + ...python-security-backports-35310940870.yaml | 6 +- scripts/test_embedded_python_security.py | 15 +- 5 files changed, 956 insertions(+), 11 deletions(-) create mode 100644 deps/cpython/0006-CVE-2026-17084-stringprep-unicode.patch diff --git a/deps/cpython/0006-CVE-2026-17084-stringprep-unicode.patch b/deps/cpython/0006-CVE-2026-17084-stringprep-unicode.patch new file mode 100644 index 000000000000..a9430f16a7ca --- /dev/null +++ b/deps/cpython/0006-CVE-2026-17084-stringprep-unicode.patch @@ -0,0 +1,930 @@ +From c28b121a4f0b975937c8b5a1b4934bb361d84296 Mon Sep 17 00:00:00 2001 +From: "Miss Islington (bot)" + <31488909+miss-islington@users.noreply.github.com> +Date: Wed, 9 Sep 2026 07:28:35 -0700 +Subject: [PATCH] [3.13] gh-155292: Don't consider Unicode codepoint attributes + outside RFC 3454 (GH-155293) (GH-156020) (GH-156922) + +Due to a bug, some Unicode codepoint attributes were considered +for characters not yet defined in Unicode 3.2.0 or attributes +which changed in later Unicode versions. RFC 3454 (StringPrep) +requires using Unicode 3.2.0 strictly. + +(cherry picked from commit 7e109d084d55e7eb25837a5f3b47ef9beee547bc) +The cherry-pick needed reworking as GH-144815 wasn't backported to 3.14 +and below, so unassigned characters don't have bidi values. +(cherry picked from commit 1e54caa096678a38afcabecabb1ff72400dd6bae) + +Co-authored-by: Petr Viktorin +Co-authored-by: Seth Larson +Co-authored-by: Stan Ulbrych <89152624+stanfromireland@users.noreply.github.com> +Co-authored-by: Petr Viktorin + +--------- + +Co-authored-by: Petr Viktorin +Co-authored-by: Seth Larson +Co-authored-by: Stan Ulbrych <89152624+stanfromireland@users.noreply.github.com> +--- + Lib/stringprep.py | 477 ++++++++++++------ + Lib/test/test_codecs.py | 9 + + Lib/test/test_unicodedata.py | 9 + + ...-08-06-11-43-20.gh-issue-155292.j4pHBO.rst | 2 + + Modules/unicodedata_db.h | 2 +- + Modules/unicodename_db.h | 2 +- + Objects/unicodetype_db.h | 2 +- + Tools/unicode/makeunicodedata.py | 15 + + Tools/unicode/mkstringprep.py | 93 ++-- + 9 files changed, 428 insertions(+), 183 deletions(-) + create mode 100644 Misc/NEWS.d/next/Security/2026-08-06-11-43-20.gh-issue-155292.j4pHBO.rst + +diff --git a/Lib/stringprep.py b/Lib/stringprep.py +index 44ecdb266ce8b95..b79b37a549beda2 100644 +--- a/Lib/stringprep.py ++++ b/Lib/stringprep.py +@@ -5,12 +5,18 @@ + and mappings, for which a mapping function is provided. + """ + +-from unicodedata import ucd_3_2_0 as unicodedata ++# This check asserts that mkstringprep.py has been run ++# when unicodedata is modified to ensure conformant behavior. ++import unicodedata + +-assert unicodedata.unidata_version == '3.2.0' ++assert unicodedata.unidata_version == '15.1.0' ++ ++from unicodedata import ucd_3_2_0 as unicodedata_320 ++ ++assert unicodedata_320.unidata_version == '3.2.0' + + def in_table_a1(code): +- if unicodedata.category(code) != 'Cn': return False ++ if unicodedata_320.category(code) != 'Cn': return False + c = ord(code) + if 0xFDD0 <= c < 0xFDF0: return False + return (c & 0xFFFF) not in (0xFFFE, 0xFFFF) +@@ -22,14 +28,69 @@ def in_table_b1(code): + + + b3_exceptions = { +-0xb5:'\u03bc', 0xdf:'ss', 0x130:'i\u0307', 0x149:'\u02bcn', +-0x17f:'s', 0x1f0:'j\u030c', 0x345:'\u03b9', 0x37a:' \u03b9', +-0x390:'\u03b9\u0308\u0301', 0x3b0:'\u03c5\u0308\u0301', 0x3c2:'\u03c3', 0x3d0:'\u03b2', +-0x3d1:'\u03b8', 0x3d2:'\u03c5', 0x3d3:'\u03cd', 0x3d4:'\u03cb', +-0x3d5:'\u03c6', 0x3d6:'\u03c0', 0x3f0:'\u03ba', 0x3f1:'\u03c1', +-0x3f2:'\u03c3', 0x3f5:'\u03b5', 0x587:'\u0565\u0582', 0x1e96:'h\u0331', ++0xb5:'\u03bc', 0xdf:'ss', 0x149:'\u02bcn', 0x17f:'s', ++0x1f0:'j\u030c', 0x23a:'\u023a', 0x23b:'\u023b', 0x23d:'\u023d', ++0x23e:'\u023e', 0x241:'\u0241', 0x243:'\u0243', 0x244:'\u0244', ++0x245:'\u0245', 0x246:'\u0246', 0x248:'\u0248', 0x24a:'\u024a', ++0x24c:'\u024c', 0x24e:'\u024e', 0x345:'\u03b9', 0x370:'\u0370', ++0x372:'\u0372', 0x376:'\u0376', 0x37a:' \u03b9', 0x37f:'\u037f', ++0x390:'\u03b9\u0308\u0301', 0x3b0:'\u03c5\u0308\u0301', 0x3c2:'\u03c3', 0x3cf:'\u03cf', ++0x3d0:'\u03b2', 0x3d1:'\u03b8', 0x3d2:'\u03c5', 0x3d3:'\u03cd', ++0x3d4:'\u03cb', 0x3d5:'\u03c6', 0x3d6:'\u03c0', 0x3f0:'\u03ba', ++0x3f1:'\u03c1', 0x3f2:'\u03c3', 0x3f5:'\u03b5', 0x3f7:'\u03f7', ++0x3f9:'\u03f9', 0x3fa:'\u03fa', 0x3fd:'\u03fd', 0x3fe:'\u03fe', ++0x3ff:'\u03ff', 0x4c0:'\u04c0', 0x4f6:'\u04f6', 0x4fa:'\u04fa', ++0x4fc:'\u04fc', 0x4fe:'\u04fe', 0x510:'\u0510', 0x512:'\u0512', ++0x514:'\u0514', 0x516:'\u0516', 0x518:'\u0518', 0x51a:'\u051a', ++0x51c:'\u051c', 0x51e:'\u051e', 0x520:'\u0520', 0x522:'\u0522', ++0x524:'\u0524', 0x526:'\u0526', 0x528:'\u0528', 0x52a:'\u052a', ++0x52c:'\u052c', 0x52e:'\u052e', 0x587:'\u0565\u0582', 0x10a0:'\u10a0', ++0x10a1:'\u10a1', 0x10a2:'\u10a2', 0x10a3:'\u10a3', 0x10a4:'\u10a4', ++0x10a5:'\u10a5', 0x10a6:'\u10a6', 0x10a7:'\u10a7', 0x10a8:'\u10a8', ++0x10a9:'\u10a9', 0x10aa:'\u10aa', 0x10ab:'\u10ab', 0x10ac:'\u10ac', ++0x10ad:'\u10ad', 0x10ae:'\u10ae', 0x10af:'\u10af', 0x10b0:'\u10b0', ++0x10b1:'\u10b1', 0x10b2:'\u10b2', 0x10b3:'\u10b3', 0x10b4:'\u10b4', ++0x10b5:'\u10b5', 0x10b6:'\u10b6', 0x10b7:'\u10b7', 0x10b8:'\u10b8', ++0x10b9:'\u10b9', 0x10ba:'\u10ba', 0x10bb:'\u10bb', 0x10bc:'\u10bc', ++0x10bd:'\u10bd', 0x10be:'\u10be', 0x10bf:'\u10bf', 0x10c0:'\u10c0', ++0x10c1:'\u10c1', 0x10c2:'\u10c2', 0x10c3:'\u10c3', 0x10c4:'\u10c4', ++0x10c5:'\u10c5', 0x10c7:'\u10c7', 0x10cd:'\u10cd', 0x13a0:'\u13a0', ++0x13a1:'\u13a1', 0x13a2:'\u13a2', 0x13a3:'\u13a3', 0x13a4:'\u13a4', ++0x13a5:'\u13a5', 0x13a6:'\u13a6', 0x13a7:'\u13a7', 0x13a8:'\u13a8', ++0x13a9:'\u13a9', 0x13aa:'\u13aa', 0x13ab:'\u13ab', 0x13ac:'\u13ac', ++0x13ad:'\u13ad', 0x13ae:'\u13ae', 0x13af:'\u13af', 0x13b0:'\u13b0', ++0x13b1:'\u13b1', 0x13b2:'\u13b2', 0x13b3:'\u13b3', 0x13b4:'\u13b4', ++0x13b5:'\u13b5', 0x13b6:'\u13b6', 0x13b7:'\u13b7', 0x13b8:'\u13b8', ++0x13b9:'\u13b9', 0x13ba:'\u13ba', 0x13bb:'\u13bb', 0x13bc:'\u13bc', ++0x13bd:'\u13bd', 0x13be:'\u13be', 0x13bf:'\u13bf', 0x13c0:'\u13c0', ++0x13c1:'\u13c1', 0x13c2:'\u13c2', 0x13c3:'\u13c3', 0x13c4:'\u13c4', ++0x13c5:'\u13c5', 0x13c6:'\u13c6', 0x13c7:'\u13c7', 0x13c8:'\u13c8', ++0x13c9:'\u13c9', 0x13ca:'\u13ca', 0x13cb:'\u13cb', 0x13cc:'\u13cc', ++0x13cd:'\u13cd', 0x13ce:'\u13ce', 0x13cf:'\u13cf', 0x13d0:'\u13d0', ++0x13d1:'\u13d1', 0x13d2:'\u13d2', 0x13d3:'\u13d3', 0x13d4:'\u13d4', ++0x13d5:'\u13d5', 0x13d6:'\u13d6', 0x13d7:'\u13d7', 0x13d8:'\u13d8', ++0x13d9:'\u13d9', 0x13da:'\u13da', 0x13db:'\u13db', 0x13dc:'\u13dc', ++0x13dd:'\u13dd', 0x13de:'\u13de', 0x13df:'\u13df', 0x13e0:'\u13e0', ++0x13e1:'\u13e1', 0x13e2:'\u13e2', 0x13e3:'\u13e3', 0x13e4:'\u13e4', ++0x13e5:'\u13e5', 0x13e6:'\u13e6', 0x13e7:'\u13e7', 0x13e8:'\u13e8', ++0x13e9:'\u13e9', 0x13ea:'\u13ea', 0x13eb:'\u13eb', 0x13ec:'\u13ec', ++0x13ed:'\u13ed', 0x13ee:'\u13ee', 0x13ef:'\u13ef', 0x13f0:'\u13f0', ++0x13f1:'\u13f1', 0x13f2:'\u13f2', 0x13f3:'\u13f3', 0x13f4:'\u13f4', ++0x13f5:'\u13f5', 0x1c90:'\u1c90', 0x1c91:'\u1c91', 0x1c92:'\u1c92', ++0x1c93:'\u1c93', 0x1c94:'\u1c94', 0x1c95:'\u1c95', 0x1c96:'\u1c96', ++0x1c97:'\u1c97', 0x1c98:'\u1c98', 0x1c99:'\u1c99', 0x1c9a:'\u1c9a', ++0x1c9b:'\u1c9b', 0x1c9c:'\u1c9c', 0x1c9d:'\u1c9d', 0x1c9e:'\u1c9e', ++0x1c9f:'\u1c9f', 0x1ca0:'\u1ca0', 0x1ca1:'\u1ca1', 0x1ca2:'\u1ca2', ++0x1ca3:'\u1ca3', 0x1ca4:'\u1ca4', 0x1ca5:'\u1ca5', 0x1ca6:'\u1ca6', ++0x1ca7:'\u1ca7', 0x1ca8:'\u1ca8', 0x1ca9:'\u1ca9', 0x1caa:'\u1caa', ++0x1cab:'\u1cab', 0x1cac:'\u1cac', 0x1cad:'\u1cad', 0x1cae:'\u1cae', ++0x1caf:'\u1caf', 0x1cb0:'\u1cb0', 0x1cb1:'\u1cb1', 0x1cb2:'\u1cb2', ++0x1cb3:'\u1cb3', 0x1cb4:'\u1cb4', 0x1cb5:'\u1cb5', 0x1cb6:'\u1cb6', ++0x1cb7:'\u1cb7', 0x1cb8:'\u1cb8', 0x1cb9:'\u1cb9', 0x1cba:'\u1cba', ++0x1cbd:'\u1cbd', 0x1cbe:'\u1cbe', 0x1cbf:'\u1cbf', 0x1e96:'h\u0331', + 0x1e97:'t\u0308', 0x1e98:'w\u030a', 0x1e99:'y\u030a', 0x1e9a:'a\u02be', +-0x1e9b:'\u1e61', 0x1f50:'\u03c5\u0313', 0x1f52:'\u03c5\u0313\u0300', 0x1f54:'\u03c5\u0313\u0301', ++0x1e9b:'\u1e61', 0x1e9e:'\u1e9e', 0x1efa:'\u1efa', 0x1efc:'\u1efc', ++0x1efe:'\u1efe', 0x1f50:'\u03c5\u0313', 0x1f52:'\u03c5\u0313\u0300', 0x1f54:'\u03c5\u0313\u0301', + 0x1f56:'\u03c5\u0313\u0342', 0x1f80:'\u1f00\u03b9', 0x1f81:'\u1f01\u03b9', 0x1f82:'\u1f02\u03b9', + 0x1f83:'\u1f03\u03b9', 0x1f84:'\u1f04\u03b9', 0x1f85:'\u1f05\u03b9', 0x1f86:'\u1f06\u03b9', + 0x1f87:'\u1f07\u03b9', 0x1f88:'\u1f00\u03b9', 0x1f89:'\u1f01\u03b9', 0x1f8a:'\u1f02\u03b9', +@@ -56,135 +117,251 @@ def in_table_b1(code): + 0x211b:'r', 0x211c:'r', 0x211d:'r', 0x2120:'sm', + 0x2121:'tel', 0x2122:'tm', 0x2124:'z', 0x2128:'z', + 0x212c:'b', 0x212d:'c', 0x2130:'e', 0x2131:'f', +-0x2133:'m', 0x213e:'\u03b3', 0x213f:'\u03c0', 0x2145:'d', +-0x3371:'hpa', 0x3373:'au', 0x3375:'ov', 0x3380:'pa', +-0x3381:'na', 0x3382:'\u03bca', 0x3383:'ma', 0x3384:'ka', +-0x3385:'kb', 0x3386:'mb', 0x3387:'gb', 0x338a:'pf', +-0x338b:'nf', 0x338c:'\u03bcf', 0x3390:'hz', 0x3391:'khz', +-0x3392:'mhz', 0x3393:'ghz', 0x3394:'thz', 0x33a9:'pa', +-0x33aa:'kpa', 0x33ab:'mpa', 0x33ac:'gpa', 0x33b4:'pv', +-0x33b5:'nv', 0x33b6:'\u03bcv', 0x33b7:'mv', 0x33b8:'kv', +-0x33b9:'mv', 0x33ba:'pw', 0x33bb:'nw', 0x33bc:'\u03bcw', +-0x33bd:'mw', 0x33be:'kw', 0x33bf:'mw', 0x33c0:'k\u03c9', +-0x33c1:'m\u03c9', 0x33c3:'bq', 0x33c6:'c\u2215kg', 0x33c7:'co.', +-0x33c8:'db', 0x33c9:'gy', 0x33cb:'hp', 0x33cd:'kk', +-0x33ce:'km', 0x33d7:'ph', 0x33d9:'ppm', 0x33da:'pr', +-0x33dc:'sv', 0x33dd:'wb', 0xfb00:'ff', 0xfb01:'fi', +-0xfb02:'fl', 0xfb03:'ffi', 0xfb04:'ffl', 0xfb05:'st', +-0xfb06:'st', 0xfb13:'\u0574\u0576', 0xfb14:'\u0574\u0565', 0xfb15:'\u0574\u056b', +-0xfb16:'\u057e\u0576', 0xfb17:'\u0574\u056d', 0x1d400:'a', 0x1d401:'b', +-0x1d402:'c', 0x1d403:'d', 0x1d404:'e', 0x1d405:'f', +-0x1d406:'g', 0x1d407:'h', 0x1d408:'i', 0x1d409:'j', +-0x1d40a:'k', 0x1d40b:'l', 0x1d40c:'m', 0x1d40d:'n', +-0x1d40e:'o', 0x1d40f:'p', 0x1d410:'q', 0x1d411:'r', +-0x1d412:'s', 0x1d413:'t', 0x1d414:'u', 0x1d415:'v', +-0x1d416:'w', 0x1d417:'x', 0x1d418:'y', 0x1d419:'z', +-0x1d434:'a', 0x1d435:'b', 0x1d436:'c', 0x1d437:'d', +-0x1d438:'e', 0x1d439:'f', 0x1d43a:'g', 0x1d43b:'h', +-0x1d43c:'i', 0x1d43d:'j', 0x1d43e:'k', 0x1d43f:'l', +-0x1d440:'m', 0x1d441:'n', 0x1d442:'o', 0x1d443:'p', +-0x1d444:'q', 0x1d445:'r', 0x1d446:'s', 0x1d447:'t', +-0x1d448:'u', 0x1d449:'v', 0x1d44a:'w', 0x1d44b:'x', +-0x1d44c:'y', 0x1d44d:'z', 0x1d468:'a', 0x1d469:'b', +-0x1d46a:'c', 0x1d46b:'d', 0x1d46c:'e', 0x1d46d:'f', +-0x1d46e:'g', 0x1d46f:'h', 0x1d470:'i', 0x1d471:'j', +-0x1d472:'k', 0x1d473:'l', 0x1d474:'m', 0x1d475:'n', +-0x1d476:'o', 0x1d477:'p', 0x1d478:'q', 0x1d479:'r', +-0x1d47a:'s', 0x1d47b:'t', 0x1d47c:'u', 0x1d47d:'v', +-0x1d47e:'w', 0x1d47f:'x', 0x1d480:'y', 0x1d481:'z', +-0x1d49c:'a', 0x1d49e:'c', 0x1d49f:'d', 0x1d4a2:'g', +-0x1d4a5:'j', 0x1d4a6:'k', 0x1d4a9:'n', 0x1d4aa:'o', +-0x1d4ab:'p', 0x1d4ac:'q', 0x1d4ae:'s', 0x1d4af:'t', +-0x1d4b0:'u', 0x1d4b1:'v', 0x1d4b2:'w', 0x1d4b3:'x', +-0x1d4b4:'y', 0x1d4b5:'z', 0x1d4d0:'a', 0x1d4d1:'b', +-0x1d4d2:'c', 0x1d4d3:'d', 0x1d4d4:'e', 0x1d4d5:'f', +-0x1d4d6:'g', 0x1d4d7:'h', 0x1d4d8:'i', 0x1d4d9:'j', +-0x1d4da:'k', 0x1d4db:'l', 0x1d4dc:'m', 0x1d4dd:'n', +-0x1d4de:'o', 0x1d4df:'p', 0x1d4e0:'q', 0x1d4e1:'r', +-0x1d4e2:'s', 0x1d4e3:'t', 0x1d4e4:'u', 0x1d4e5:'v', +-0x1d4e6:'w', 0x1d4e7:'x', 0x1d4e8:'y', 0x1d4e9:'z', +-0x1d504:'a', 0x1d505:'b', 0x1d507:'d', 0x1d508:'e', +-0x1d509:'f', 0x1d50a:'g', 0x1d50d:'j', 0x1d50e:'k', +-0x1d50f:'l', 0x1d510:'m', 0x1d511:'n', 0x1d512:'o', +-0x1d513:'p', 0x1d514:'q', 0x1d516:'s', 0x1d517:'t', +-0x1d518:'u', 0x1d519:'v', 0x1d51a:'w', 0x1d51b:'x', +-0x1d51c:'y', 0x1d538:'a', 0x1d539:'b', 0x1d53b:'d', +-0x1d53c:'e', 0x1d53d:'f', 0x1d53e:'g', 0x1d540:'i', +-0x1d541:'j', 0x1d542:'k', 0x1d543:'l', 0x1d544:'m', +-0x1d546:'o', 0x1d54a:'s', 0x1d54b:'t', 0x1d54c:'u', +-0x1d54d:'v', 0x1d54e:'w', 0x1d54f:'x', 0x1d550:'y', +-0x1d56c:'a', 0x1d56d:'b', 0x1d56e:'c', 0x1d56f:'d', +-0x1d570:'e', 0x1d571:'f', 0x1d572:'g', 0x1d573:'h', +-0x1d574:'i', 0x1d575:'j', 0x1d576:'k', 0x1d577:'l', +-0x1d578:'m', 0x1d579:'n', 0x1d57a:'o', 0x1d57b:'p', +-0x1d57c:'q', 0x1d57d:'r', 0x1d57e:'s', 0x1d57f:'t', +-0x1d580:'u', 0x1d581:'v', 0x1d582:'w', 0x1d583:'x', +-0x1d584:'y', 0x1d585:'z', 0x1d5a0:'a', 0x1d5a1:'b', +-0x1d5a2:'c', 0x1d5a3:'d', 0x1d5a4:'e', 0x1d5a5:'f', +-0x1d5a6:'g', 0x1d5a7:'h', 0x1d5a8:'i', 0x1d5a9:'j', +-0x1d5aa:'k', 0x1d5ab:'l', 0x1d5ac:'m', 0x1d5ad:'n', +-0x1d5ae:'o', 0x1d5af:'p', 0x1d5b0:'q', 0x1d5b1:'r', +-0x1d5b2:'s', 0x1d5b3:'t', 0x1d5b4:'u', 0x1d5b5:'v', +-0x1d5b6:'w', 0x1d5b7:'x', 0x1d5b8:'y', 0x1d5b9:'z', +-0x1d5d4:'a', 0x1d5d5:'b', 0x1d5d6:'c', 0x1d5d7:'d', +-0x1d5d8:'e', 0x1d5d9:'f', 0x1d5da:'g', 0x1d5db:'h', +-0x1d5dc:'i', 0x1d5dd:'j', 0x1d5de:'k', 0x1d5df:'l', +-0x1d5e0:'m', 0x1d5e1:'n', 0x1d5e2:'o', 0x1d5e3:'p', +-0x1d5e4:'q', 0x1d5e5:'r', 0x1d5e6:'s', 0x1d5e7:'t', +-0x1d5e8:'u', 0x1d5e9:'v', 0x1d5ea:'w', 0x1d5eb:'x', +-0x1d5ec:'y', 0x1d5ed:'z', 0x1d608:'a', 0x1d609:'b', +-0x1d60a:'c', 0x1d60b:'d', 0x1d60c:'e', 0x1d60d:'f', +-0x1d60e:'g', 0x1d60f:'h', 0x1d610:'i', 0x1d611:'j', +-0x1d612:'k', 0x1d613:'l', 0x1d614:'m', 0x1d615:'n', +-0x1d616:'o', 0x1d617:'p', 0x1d618:'q', 0x1d619:'r', +-0x1d61a:'s', 0x1d61b:'t', 0x1d61c:'u', 0x1d61d:'v', +-0x1d61e:'w', 0x1d61f:'x', 0x1d620:'y', 0x1d621:'z', +-0x1d63c:'a', 0x1d63d:'b', 0x1d63e:'c', 0x1d63f:'d', +-0x1d640:'e', 0x1d641:'f', 0x1d642:'g', 0x1d643:'h', +-0x1d644:'i', 0x1d645:'j', 0x1d646:'k', 0x1d647:'l', +-0x1d648:'m', 0x1d649:'n', 0x1d64a:'o', 0x1d64b:'p', +-0x1d64c:'q', 0x1d64d:'r', 0x1d64e:'s', 0x1d64f:'t', +-0x1d650:'u', 0x1d651:'v', 0x1d652:'w', 0x1d653:'x', +-0x1d654:'y', 0x1d655:'z', 0x1d670:'a', 0x1d671:'b', +-0x1d672:'c', 0x1d673:'d', 0x1d674:'e', 0x1d675:'f', +-0x1d676:'g', 0x1d677:'h', 0x1d678:'i', 0x1d679:'j', +-0x1d67a:'k', 0x1d67b:'l', 0x1d67c:'m', 0x1d67d:'n', +-0x1d67e:'o', 0x1d67f:'p', 0x1d680:'q', 0x1d681:'r', +-0x1d682:'s', 0x1d683:'t', 0x1d684:'u', 0x1d685:'v', +-0x1d686:'w', 0x1d687:'x', 0x1d688:'y', 0x1d689:'z', +-0x1d6a8:'\u03b1', 0x1d6a9:'\u03b2', 0x1d6aa:'\u03b3', 0x1d6ab:'\u03b4', +-0x1d6ac:'\u03b5', 0x1d6ad:'\u03b6', 0x1d6ae:'\u03b7', 0x1d6af:'\u03b8', +-0x1d6b0:'\u03b9', 0x1d6b1:'\u03ba', 0x1d6b2:'\u03bb', 0x1d6b3:'\u03bc', +-0x1d6b4:'\u03bd', 0x1d6b5:'\u03be', 0x1d6b6:'\u03bf', 0x1d6b7:'\u03c0', +-0x1d6b8:'\u03c1', 0x1d6b9:'\u03b8', 0x1d6ba:'\u03c3', 0x1d6bb:'\u03c4', +-0x1d6bc:'\u03c5', 0x1d6bd:'\u03c6', 0x1d6be:'\u03c7', 0x1d6bf:'\u03c8', +-0x1d6c0:'\u03c9', 0x1d6d3:'\u03c3', 0x1d6e2:'\u03b1', 0x1d6e3:'\u03b2', +-0x1d6e4:'\u03b3', 0x1d6e5:'\u03b4', 0x1d6e6:'\u03b5', 0x1d6e7:'\u03b6', +-0x1d6e8:'\u03b7', 0x1d6e9:'\u03b8', 0x1d6ea:'\u03b9', 0x1d6eb:'\u03ba', +-0x1d6ec:'\u03bb', 0x1d6ed:'\u03bc', 0x1d6ee:'\u03bd', 0x1d6ef:'\u03be', +-0x1d6f0:'\u03bf', 0x1d6f1:'\u03c0', 0x1d6f2:'\u03c1', 0x1d6f3:'\u03b8', +-0x1d6f4:'\u03c3', 0x1d6f5:'\u03c4', 0x1d6f6:'\u03c5', 0x1d6f7:'\u03c6', +-0x1d6f8:'\u03c7', 0x1d6f9:'\u03c8', 0x1d6fa:'\u03c9', 0x1d70d:'\u03c3', +-0x1d71c:'\u03b1', 0x1d71d:'\u03b2', 0x1d71e:'\u03b3', 0x1d71f:'\u03b4', +-0x1d720:'\u03b5', 0x1d721:'\u03b6', 0x1d722:'\u03b7', 0x1d723:'\u03b8', +-0x1d724:'\u03b9', 0x1d725:'\u03ba', 0x1d726:'\u03bb', 0x1d727:'\u03bc', +-0x1d728:'\u03bd', 0x1d729:'\u03be', 0x1d72a:'\u03bf', 0x1d72b:'\u03c0', +-0x1d72c:'\u03c1', 0x1d72d:'\u03b8', 0x1d72e:'\u03c3', 0x1d72f:'\u03c4', +-0x1d730:'\u03c5', 0x1d731:'\u03c6', 0x1d732:'\u03c7', 0x1d733:'\u03c8', +-0x1d734:'\u03c9', 0x1d747:'\u03c3', 0x1d756:'\u03b1', 0x1d757:'\u03b2', +-0x1d758:'\u03b3', 0x1d759:'\u03b4', 0x1d75a:'\u03b5', 0x1d75b:'\u03b6', +-0x1d75c:'\u03b7', 0x1d75d:'\u03b8', 0x1d75e:'\u03b9', 0x1d75f:'\u03ba', +-0x1d760:'\u03bb', 0x1d761:'\u03bc', 0x1d762:'\u03bd', 0x1d763:'\u03be', +-0x1d764:'\u03bf', 0x1d765:'\u03c0', 0x1d766:'\u03c1', 0x1d767:'\u03b8', +-0x1d768:'\u03c3', 0x1d769:'\u03c4', 0x1d76a:'\u03c5', 0x1d76b:'\u03c6', +-0x1d76c:'\u03c7', 0x1d76d:'\u03c8', 0x1d76e:'\u03c9', 0x1d781:'\u03c3', +-0x1d790:'\u03b1', 0x1d791:'\u03b2', 0x1d792:'\u03b3', 0x1d793:'\u03b4', +-0x1d794:'\u03b5', 0x1d795:'\u03b6', 0x1d796:'\u03b7', 0x1d797:'\u03b8', +-0x1d798:'\u03b9', 0x1d799:'\u03ba', 0x1d79a:'\u03bb', 0x1d79b:'\u03bc', +-0x1d79c:'\u03bd', 0x1d79d:'\u03be', 0x1d79e:'\u03bf', 0x1d79f:'\u03c0', +-0x1d7a0:'\u03c1', 0x1d7a1:'\u03b8', 0x1d7a2:'\u03c3', 0x1d7a3:'\u03c4', +-0x1d7a4:'\u03c5', 0x1d7a5:'\u03c6', 0x1d7a6:'\u03c7', 0x1d7a7:'\u03c8', +-0x1d7a8:'\u03c9', 0x1d7bb:'\u03c3', } ++0x2132:'\u2132', 0x2133:'m', 0x213e:'\u03b3', 0x213f:'\u03c0', ++0x2145:'d', 0x2183:'\u2183', 0x2c00:'\u2c00', 0x2c01:'\u2c01', ++0x2c02:'\u2c02', 0x2c03:'\u2c03', 0x2c04:'\u2c04', 0x2c05:'\u2c05', ++0x2c06:'\u2c06', 0x2c07:'\u2c07', 0x2c08:'\u2c08', 0x2c09:'\u2c09', ++0x2c0a:'\u2c0a', 0x2c0b:'\u2c0b', 0x2c0c:'\u2c0c', 0x2c0d:'\u2c0d', ++0x2c0e:'\u2c0e', 0x2c0f:'\u2c0f', 0x2c10:'\u2c10', 0x2c11:'\u2c11', ++0x2c12:'\u2c12', 0x2c13:'\u2c13', 0x2c14:'\u2c14', 0x2c15:'\u2c15', ++0x2c16:'\u2c16', 0x2c17:'\u2c17', 0x2c18:'\u2c18', 0x2c19:'\u2c19', ++0x2c1a:'\u2c1a', 0x2c1b:'\u2c1b', 0x2c1c:'\u2c1c', 0x2c1d:'\u2c1d', ++0x2c1e:'\u2c1e', 0x2c1f:'\u2c1f', 0x2c20:'\u2c20', 0x2c21:'\u2c21', ++0x2c22:'\u2c22', 0x2c23:'\u2c23', 0x2c24:'\u2c24', 0x2c25:'\u2c25', ++0x2c26:'\u2c26', 0x2c27:'\u2c27', 0x2c28:'\u2c28', 0x2c29:'\u2c29', ++0x2c2a:'\u2c2a', 0x2c2b:'\u2c2b', 0x2c2c:'\u2c2c', 0x2c2d:'\u2c2d', ++0x2c2e:'\u2c2e', 0x2c2f:'\u2c2f', 0x2c60:'\u2c60', 0x2c62:'\u2c62', ++0x2c63:'\u2c63', 0x2c64:'\u2c64', 0x2c67:'\u2c67', 0x2c69:'\u2c69', ++0x2c6b:'\u2c6b', 0x2c6d:'\u2c6d', 0x2c6e:'\u2c6e', 0x2c6f:'\u2c6f', ++0x2c70:'\u2c70', 0x2c72:'\u2c72', 0x2c75:'\u2c75', 0x2c7e:'\u2c7e', ++0x2c7f:'\u2c7f', 0x2c80:'\u2c80', 0x2c82:'\u2c82', 0x2c84:'\u2c84', ++0x2c86:'\u2c86', 0x2c88:'\u2c88', 0x2c8a:'\u2c8a', 0x2c8c:'\u2c8c', ++0x2c8e:'\u2c8e', 0x2c90:'\u2c90', 0x2c92:'\u2c92', 0x2c94:'\u2c94', ++0x2c96:'\u2c96', 0x2c98:'\u2c98', 0x2c9a:'\u2c9a', 0x2c9c:'\u2c9c', ++0x2c9e:'\u2c9e', 0x2ca0:'\u2ca0', 0x2ca2:'\u2ca2', 0x2ca4:'\u2ca4', ++0x2ca6:'\u2ca6', 0x2ca8:'\u2ca8', 0x2caa:'\u2caa', 0x2cac:'\u2cac', ++0x2cae:'\u2cae', 0x2cb0:'\u2cb0', 0x2cb2:'\u2cb2', 0x2cb4:'\u2cb4', ++0x2cb6:'\u2cb6', 0x2cb8:'\u2cb8', 0x2cba:'\u2cba', 0x2cbc:'\u2cbc', ++0x2cbe:'\u2cbe', 0x2cc0:'\u2cc0', 0x2cc2:'\u2cc2', 0x2cc4:'\u2cc4', ++0x2cc6:'\u2cc6', 0x2cc8:'\u2cc8', 0x2cca:'\u2cca', 0x2ccc:'\u2ccc', ++0x2cce:'\u2cce', 0x2cd0:'\u2cd0', 0x2cd2:'\u2cd2', 0x2cd4:'\u2cd4', ++0x2cd6:'\u2cd6', 0x2cd8:'\u2cd8', 0x2cda:'\u2cda', 0x2cdc:'\u2cdc', ++0x2cde:'\u2cde', 0x2ce0:'\u2ce0', 0x2ce2:'\u2ce2', 0x2ceb:'\u2ceb', ++0x2ced:'\u2ced', 0x2cf2:'\u2cf2', 0x3371:'hpa', 0x3373:'au', ++0x3375:'ov', 0x3380:'pa', 0x3381:'na', 0x3382:'\u03bca', ++0x3383:'ma', 0x3384:'ka', 0x3385:'kb', 0x3386:'mb', ++0x3387:'gb', 0x338a:'pf', 0x338b:'nf', 0x338c:'\u03bcf', ++0x3390:'hz', 0x3391:'khz', 0x3392:'mhz', 0x3393:'ghz', ++0x3394:'thz', 0x33a9:'pa', 0x33aa:'kpa', 0x33ab:'mpa', ++0x33ac:'gpa', 0x33b4:'pv', 0x33b5:'nv', 0x33b6:'\u03bcv', ++0x33b7:'mv', 0x33b8:'kv', 0x33b9:'mv', 0x33ba:'pw', ++0x33bb:'nw', 0x33bc:'\u03bcw', 0x33bd:'mw', 0x33be:'kw', ++0x33bf:'mw', 0x33c0:'k\u03c9', 0x33c1:'m\u03c9', 0x33c3:'bq', ++0x33c6:'c\u2215kg', 0x33c7:'co.', 0x33c8:'db', 0x33c9:'gy', ++0x33cb:'hp', 0x33cd:'kk', 0x33ce:'km', 0x33d7:'ph', ++0x33d9:'ppm', 0x33da:'pr', 0x33dc:'sv', 0x33dd:'wb', ++0xa640:'\ua640', 0xa642:'\ua642', 0xa644:'\ua644', 0xa646:'\ua646', ++0xa648:'\ua648', 0xa64a:'\ua64a', 0xa64c:'\ua64c', 0xa64e:'\ua64e', ++0xa650:'\ua650', 0xa652:'\ua652', 0xa654:'\ua654', 0xa656:'\ua656', ++0xa658:'\ua658', 0xa65a:'\ua65a', 0xa65c:'\ua65c', 0xa65e:'\ua65e', ++0xa660:'\ua660', 0xa662:'\ua662', 0xa664:'\ua664', 0xa666:'\ua666', ++0xa668:'\ua668', 0xa66a:'\ua66a', 0xa66c:'\ua66c', 0xa680:'\ua680', ++0xa682:'\ua682', 0xa684:'\ua684', 0xa686:'\ua686', 0xa688:'\ua688', ++0xa68a:'\ua68a', 0xa68c:'\ua68c', 0xa68e:'\ua68e', 0xa690:'\ua690', ++0xa692:'\ua692', 0xa694:'\ua694', 0xa696:'\ua696', 0xa698:'\ua698', ++0xa69a:'\ua69a', 0xa722:'\ua722', 0xa724:'\ua724', 0xa726:'\ua726', ++0xa728:'\ua728', 0xa72a:'\ua72a', 0xa72c:'\ua72c', 0xa72e:'\ua72e', ++0xa732:'\ua732', 0xa734:'\ua734', 0xa736:'\ua736', 0xa738:'\ua738', ++0xa73a:'\ua73a', 0xa73c:'\ua73c', 0xa73e:'\ua73e', 0xa740:'\ua740', ++0xa742:'\ua742', 0xa744:'\ua744', 0xa746:'\ua746', 0xa748:'\ua748', ++0xa74a:'\ua74a', 0xa74c:'\ua74c', 0xa74e:'\ua74e', 0xa750:'\ua750', ++0xa752:'\ua752', 0xa754:'\ua754', 0xa756:'\ua756', 0xa758:'\ua758', ++0xa75a:'\ua75a', 0xa75c:'\ua75c', 0xa75e:'\ua75e', 0xa760:'\ua760', ++0xa762:'\ua762', 0xa764:'\ua764', 0xa766:'\ua766', 0xa768:'\ua768', ++0xa76a:'\ua76a', 0xa76c:'\ua76c', 0xa76e:'\ua76e', 0xa779:'\ua779', ++0xa77b:'\ua77b', 0xa77d:'\ua77d', 0xa77e:'\ua77e', 0xa780:'\ua780', ++0xa782:'\ua782', 0xa784:'\ua784', 0xa786:'\ua786', 0xa78b:'\ua78b', ++0xa78d:'\ua78d', 0xa790:'\ua790', 0xa792:'\ua792', 0xa796:'\ua796', ++0xa798:'\ua798', 0xa79a:'\ua79a', 0xa79c:'\ua79c', 0xa79e:'\ua79e', ++0xa7a0:'\ua7a0', 0xa7a2:'\ua7a2', 0xa7a4:'\ua7a4', 0xa7a6:'\ua7a6', ++0xa7a8:'\ua7a8', 0xa7aa:'\ua7aa', 0xa7ab:'\ua7ab', 0xa7ac:'\ua7ac', ++0xa7ad:'\ua7ad', 0xa7ae:'\ua7ae', 0xa7b0:'\ua7b0', 0xa7b1:'\ua7b1', ++0xa7b2:'\ua7b2', 0xa7b3:'\ua7b3', 0xa7b4:'\ua7b4', 0xa7b6:'\ua7b6', ++0xa7b8:'\ua7b8', 0xa7ba:'\ua7ba', 0xa7bc:'\ua7bc', 0xa7be:'\ua7be', ++0xa7c0:'\ua7c0', 0xa7c2:'\ua7c2', 0xa7c4:'\ua7c4', 0xa7c5:'\ua7c5', ++0xa7c6:'\ua7c6', 0xa7c7:'\ua7c7', 0xa7c9:'\ua7c9', 0xa7d0:'\ua7d0', ++0xa7d6:'\ua7d6', 0xa7d8:'\ua7d8', 0xa7f5:'\ua7f5', 0xfb00:'ff', ++0xfb01:'fi', 0xfb02:'fl', 0xfb03:'ffi', 0xfb04:'ffl', ++0xfb05:'st', 0xfb06:'st', 0xfb13:'\u0574\u0576', 0xfb14:'\u0574\u0565', ++0xfb15:'\u0574\u056b', 0xfb16:'\u057e\u0576', 0xfb17:'\u0574\u056d', 0x10426:'\U00010426', ++0x10427:'\U00010427', 0x104b0:'\U000104b0', 0x104b1:'\U000104b1', 0x104b2:'\U000104b2', ++0x104b3:'\U000104b3', 0x104b4:'\U000104b4', 0x104b5:'\U000104b5', 0x104b6:'\U000104b6', ++0x104b7:'\U000104b7', 0x104b8:'\U000104b8', 0x104b9:'\U000104b9', 0x104ba:'\U000104ba', ++0x104bb:'\U000104bb', 0x104bc:'\U000104bc', 0x104bd:'\U000104bd', 0x104be:'\U000104be', ++0x104bf:'\U000104bf', 0x104c0:'\U000104c0', 0x104c1:'\U000104c1', 0x104c2:'\U000104c2', ++0x104c3:'\U000104c3', 0x104c4:'\U000104c4', 0x104c5:'\U000104c5', 0x104c6:'\U000104c6', ++0x104c7:'\U000104c7', 0x104c8:'\U000104c8', 0x104c9:'\U000104c9', 0x104ca:'\U000104ca', ++0x104cb:'\U000104cb', 0x104cc:'\U000104cc', 0x104cd:'\U000104cd', 0x104ce:'\U000104ce', ++0x104cf:'\U000104cf', 0x104d0:'\U000104d0', 0x104d1:'\U000104d1', 0x104d2:'\U000104d2', ++0x104d3:'\U000104d3', 0x10570:'\U00010570', 0x10571:'\U00010571', 0x10572:'\U00010572', ++0x10573:'\U00010573', 0x10574:'\U00010574', 0x10575:'\U00010575', 0x10576:'\U00010576', ++0x10577:'\U00010577', 0x10578:'\U00010578', 0x10579:'\U00010579', 0x1057a:'\U0001057a', ++0x1057c:'\U0001057c', 0x1057d:'\U0001057d', 0x1057e:'\U0001057e', 0x1057f:'\U0001057f', ++0x10580:'\U00010580', 0x10581:'\U00010581', 0x10582:'\U00010582', 0x10583:'\U00010583', ++0x10584:'\U00010584', 0x10585:'\U00010585', 0x10586:'\U00010586', 0x10587:'\U00010587', ++0x10588:'\U00010588', 0x10589:'\U00010589', 0x1058a:'\U0001058a', 0x1058c:'\U0001058c', ++0x1058d:'\U0001058d', 0x1058e:'\U0001058e', 0x1058f:'\U0001058f', 0x10590:'\U00010590', ++0x10591:'\U00010591', 0x10592:'\U00010592', 0x10594:'\U00010594', 0x10595:'\U00010595', ++0x10c80:'\U00010c80', 0x10c81:'\U00010c81', 0x10c82:'\U00010c82', 0x10c83:'\U00010c83', ++0x10c84:'\U00010c84', 0x10c85:'\U00010c85', 0x10c86:'\U00010c86', 0x10c87:'\U00010c87', ++0x10c88:'\U00010c88', 0x10c89:'\U00010c89', 0x10c8a:'\U00010c8a', 0x10c8b:'\U00010c8b', ++0x10c8c:'\U00010c8c', 0x10c8d:'\U00010c8d', 0x10c8e:'\U00010c8e', 0x10c8f:'\U00010c8f', ++0x10c90:'\U00010c90', 0x10c91:'\U00010c91', 0x10c92:'\U00010c92', 0x10c93:'\U00010c93', ++0x10c94:'\U00010c94', 0x10c95:'\U00010c95', 0x10c96:'\U00010c96', 0x10c97:'\U00010c97', ++0x10c98:'\U00010c98', 0x10c99:'\U00010c99', 0x10c9a:'\U00010c9a', 0x10c9b:'\U00010c9b', ++0x10c9c:'\U00010c9c', 0x10c9d:'\U00010c9d', 0x10c9e:'\U00010c9e', 0x10c9f:'\U00010c9f', ++0x10ca0:'\U00010ca0', 0x10ca1:'\U00010ca1', 0x10ca2:'\U00010ca2', 0x10ca3:'\U00010ca3', ++0x10ca4:'\U00010ca4', 0x10ca5:'\U00010ca5', 0x10ca6:'\U00010ca6', 0x10ca7:'\U00010ca7', ++0x10ca8:'\U00010ca8', 0x10ca9:'\U00010ca9', 0x10caa:'\U00010caa', 0x10cab:'\U00010cab', ++0x10cac:'\U00010cac', 0x10cad:'\U00010cad', 0x10cae:'\U00010cae', 0x10caf:'\U00010caf', ++0x10cb0:'\U00010cb0', 0x10cb1:'\U00010cb1', 0x10cb2:'\U00010cb2', 0x118a0:'\U000118a0', ++0x118a1:'\U000118a1', 0x118a2:'\U000118a2', 0x118a3:'\U000118a3', 0x118a4:'\U000118a4', ++0x118a5:'\U000118a5', 0x118a6:'\U000118a6', 0x118a7:'\U000118a7', 0x118a8:'\U000118a8', ++0x118a9:'\U000118a9', 0x118aa:'\U000118aa', 0x118ab:'\U000118ab', 0x118ac:'\U000118ac', ++0x118ad:'\U000118ad', 0x118ae:'\U000118ae', 0x118af:'\U000118af', 0x118b0:'\U000118b0', ++0x118b1:'\U000118b1', 0x118b2:'\U000118b2', 0x118b3:'\U000118b3', 0x118b4:'\U000118b4', ++0x118b5:'\U000118b5', 0x118b6:'\U000118b6', 0x118b7:'\U000118b7', 0x118b8:'\U000118b8', ++0x118b9:'\U000118b9', 0x118ba:'\U000118ba', 0x118bb:'\U000118bb', 0x118bc:'\U000118bc', ++0x118bd:'\U000118bd', 0x118be:'\U000118be', 0x118bf:'\U000118bf', 0x16e40:'\U00016e40', ++0x16e41:'\U00016e41', 0x16e42:'\U00016e42', 0x16e43:'\U00016e43', 0x16e44:'\U00016e44', ++0x16e45:'\U00016e45', 0x16e46:'\U00016e46', 0x16e47:'\U00016e47', 0x16e48:'\U00016e48', ++0x16e49:'\U00016e49', 0x16e4a:'\U00016e4a', 0x16e4b:'\U00016e4b', 0x16e4c:'\U00016e4c', ++0x16e4d:'\U00016e4d', 0x16e4e:'\U00016e4e', 0x16e4f:'\U00016e4f', 0x16e50:'\U00016e50', ++0x16e51:'\U00016e51', 0x16e52:'\U00016e52', 0x16e53:'\U00016e53', 0x16e54:'\U00016e54', ++0x16e55:'\U00016e55', 0x16e56:'\U00016e56', 0x16e57:'\U00016e57', 0x16e58:'\U00016e58', ++0x16e59:'\U00016e59', 0x16e5a:'\U00016e5a', 0x16e5b:'\U00016e5b', 0x16e5c:'\U00016e5c', ++0x16e5d:'\U00016e5d', 0x16e5e:'\U00016e5e', 0x16e5f:'\U00016e5f', 0x1d400:'a', ++0x1d401:'b', 0x1d402:'c', 0x1d403:'d', 0x1d404:'e', ++0x1d405:'f', 0x1d406:'g', 0x1d407:'h', 0x1d408:'i', ++0x1d409:'j', 0x1d40a:'k', 0x1d40b:'l', 0x1d40c:'m', ++0x1d40d:'n', 0x1d40e:'o', 0x1d40f:'p', 0x1d410:'q', ++0x1d411:'r', 0x1d412:'s', 0x1d413:'t', 0x1d414:'u', ++0x1d415:'v', 0x1d416:'w', 0x1d417:'x', 0x1d418:'y', ++0x1d419:'z', 0x1d434:'a', 0x1d435:'b', 0x1d436:'c', ++0x1d437:'d', 0x1d438:'e', 0x1d439:'f', 0x1d43a:'g', ++0x1d43b:'h', 0x1d43c:'i', 0x1d43d:'j', 0x1d43e:'k', ++0x1d43f:'l', 0x1d440:'m', 0x1d441:'n', 0x1d442:'o', ++0x1d443:'p', 0x1d444:'q', 0x1d445:'r', 0x1d446:'s', ++0x1d447:'t', 0x1d448:'u', 0x1d449:'v', 0x1d44a:'w', ++0x1d44b:'x', 0x1d44c:'y', 0x1d44d:'z', 0x1d468:'a', ++0x1d469:'b', 0x1d46a:'c', 0x1d46b:'d', 0x1d46c:'e', ++0x1d46d:'f', 0x1d46e:'g', 0x1d46f:'h', 0x1d470:'i', ++0x1d471:'j', 0x1d472:'k', 0x1d473:'l', 0x1d474:'m', ++0x1d475:'n', 0x1d476:'o', 0x1d477:'p', 0x1d478:'q', ++0x1d479:'r', 0x1d47a:'s', 0x1d47b:'t', 0x1d47c:'u', ++0x1d47d:'v', 0x1d47e:'w', 0x1d47f:'x', 0x1d480:'y', ++0x1d481:'z', 0x1d49c:'a', 0x1d49e:'c', 0x1d49f:'d', ++0x1d4a2:'g', 0x1d4a5:'j', 0x1d4a6:'k', 0x1d4a9:'n', ++0x1d4aa:'o', 0x1d4ab:'p', 0x1d4ac:'q', 0x1d4ae:'s', ++0x1d4af:'t', 0x1d4b0:'u', 0x1d4b1:'v', 0x1d4b2:'w', ++0x1d4b3:'x', 0x1d4b4:'y', 0x1d4b5:'z', 0x1d4d0:'a', ++0x1d4d1:'b', 0x1d4d2:'c', 0x1d4d3:'d', 0x1d4d4:'e', ++0x1d4d5:'f', 0x1d4d6:'g', 0x1d4d7:'h', 0x1d4d8:'i', ++0x1d4d9:'j', 0x1d4da:'k', 0x1d4db:'l', 0x1d4dc:'m', ++0x1d4dd:'n', 0x1d4de:'o', 0x1d4df:'p', 0x1d4e0:'q', ++0x1d4e1:'r', 0x1d4e2:'s', 0x1d4e3:'t', 0x1d4e4:'u', ++0x1d4e5:'v', 0x1d4e6:'w', 0x1d4e7:'x', 0x1d4e8:'y', ++0x1d4e9:'z', 0x1d504:'a', 0x1d505:'b', 0x1d507:'d', ++0x1d508:'e', 0x1d509:'f', 0x1d50a:'g', 0x1d50d:'j', ++0x1d50e:'k', 0x1d50f:'l', 0x1d510:'m', 0x1d511:'n', ++0x1d512:'o', 0x1d513:'p', 0x1d514:'q', 0x1d516:'s', ++0x1d517:'t', 0x1d518:'u', 0x1d519:'v', 0x1d51a:'w', ++0x1d51b:'x', 0x1d51c:'y', 0x1d538:'a', 0x1d539:'b', ++0x1d53b:'d', 0x1d53c:'e', 0x1d53d:'f', 0x1d53e:'g', ++0x1d540:'i', 0x1d541:'j', 0x1d542:'k', 0x1d543:'l', ++0x1d544:'m', 0x1d546:'o', 0x1d54a:'s', 0x1d54b:'t', ++0x1d54c:'u', 0x1d54d:'v', 0x1d54e:'w', 0x1d54f:'x', ++0x1d550:'y', 0x1d56c:'a', 0x1d56d:'b', 0x1d56e:'c', ++0x1d56f:'d', 0x1d570:'e', 0x1d571:'f', 0x1d572:'g', ++0x1d573:'h', 0x1d574:'i', 0x1d575:'j', 0x1d576:'k', ++0x1d577:'l', 0x1d578:'m', 0x1d579:'n', 0x1d57a:'o', ++0x1d57b:'p', 0x1d57c:'q', 0x1d57d:'r', 0x1d57e:'s', ++0x1d57f:'t', 0x1d580:'u', 0x1d581:'v', 0x1d582:'w', ++0x1d583:'x', 0x1d584:'y', 0x1d585:'z', 0x1d5a0:'a', ++0x1d5a1:'b', 0x1d5a2:'c', 0x1d5a3:'d', 0x1d5a4:'e', ++0x1d5a5:'f', 0x1d5a6:'g', 0x1d5a7:'h', 0x1d5a8:'i', ++0x1d5a9:'j', 0x1d5aa:'k', 0x1d5ab:'l', 0x1d5ac:'m', ++0x1d5ad:'n', 0x1d5ae:'o', 0x1d5af:'p', 0x1d5b0:'q', ++0x1d5b1:'r', 0x1d5b2:'s', 0x1d5b3:'t', 0x1d5b4:'u', ++0x1d5b5:'v', 0x1d5b6:'w', 0x1d5b7:'x', 0x1d5b8:'y', ++0x1d5b9:'z', 0x1d5d4:'a', 0x1d5d5:'b', 0x1d5d6:'c', ++0x1d5d7:'d', 0x1d5d8:'e', 0x1d5d9:'f', 0x1d5da:'g', ++0x1d5db:'h', 0x1d5dc:'i', 0x1d5dd:'j', 0x1d5de:'k', ++0x1d5df:'l', 0x1d5e0:'m', 0x1d5e1:'n', 0x1d5e2:'o', ++0x1d5e3:'p', 0x1d5e4:'q', 0x1d5e5:'r', 0x1d5e6:'s', ++0x1d5e7:'t', 0x1d5e8:'u', 0x1d5e9:'v', 0x1d5ea:'w', ++0x1d5eb:'x', 0x1d5ec:'y', 0x1d5ed:'z', 0x1d608:'a', ++0x1d609:'b', 0x1d60a:'c', 0x1d60b:'d', 0x1d60c:'e', ++0x1d60d:'f', 0x1d60e:'g', 0x1d60f:'h', 0x1d610:'i', ++0x1d611:'j', 0x1d612:'k', 0x1d613:'l', 0x1d614:'m', ++0x1d615:'n', 0x1d616:'o', 0x1d617:'p', 0x1d618:'q', ++0x1d619:'r', 0x1d61a:'s', 0x1d61b:'t', 0x1d61c:'u', ++0x1d61d:'v', 0x1d61e:'w', 0x1d61f:'x', 0x1d620:'y', ++0x1d621:'z', 0x1d63c:'a', 0x1d63d:'b', 0x1d63e:'c', ++0x1d63f:'d', 0x1d640:'e', 0x1d641:'f', 0x1d642:'g', ++0x1d643:'h', 0x1d644:'i', 0x1d645:'j', 0x1d646:'k', ++0x1d647:'l', 0x1d648:'m', 0x1d649:'n', 0x1d64a:'o', ++0x1d64b:'p', 0x1d64c:'q', 0x1d64d:'r', 0x1d64e:'s', ++0x1d64f:'t', 0x1d650:'u', 0x1d651:'v', 0x1d652:'w', ++0x1d653:'x', 0x1d654:'y', 0x1d655:'z', 0x1d670:'a', ++0x1d671:'b', 0x1d672:'c', 0x1d673:'d', 0x1d674:'e', ++0x1d675:'f', 0x1d676:'g', 0x1d677:'h', 0x1d678:'i', ++0x1d679:'j', 0x1d67a:'k', 0x1d67b:'l', 0x1d67c:'m', ++0x1d67d:'n', 0x1d67e:'o', 0x1d67f:'p', 0x1d680:'q', ++0x1d681:'r', 0x1d682:'s', 0x1d683:'t', 0x1d684:'u', ++0x1d685:'v', 0x1d686:'w', 0x1d687:'x', 0x1d688:'y', ++0x1d689:'z', 0x1d6a8:'\u03b1', 0x1d6a9:'\u03b2', 0x1d6aa:'\u03b3', ++0x1d6ab:'\u03b4', 0x1d6ac:'\u03b5', 0x1d6ad:'\u03b6', 0x1d6ae:'\u03b7', ++0x1d6af:'\u03b8', 0x1d6b0:'\u03b9', 0x1d6b1:'\u03ba', 0x1d6b2:'\u03bb', ++0x1d6b3:'\u03bc', 0x1d6b4:'\u03bd', 0x1d6b5:'\u03be', 0x1d6b6:'\u03bf', ++0x1d6b7:'\u03c0', 0x1d6b8:'\u03c1', 0x1d6b9:'\u03b8', 0x1d6ba:'\u03c3', ++0x1d6bb:'\u03c4', 0x1d6bc:'\u03c5', 0x1d6bd:'\u03c6', 0x1d6be:'\u03c7', ++0x1d6bf:'\u03c8', 0x1d6c0:'\u03c9', 0x1d6d3:'\u03c3', 0x1d6e2:'\u03b1', ++0x1d6e3:'\u03b2', 0x1d6e4:'\u03b3', 0x1d6e5:'\u03b4', 0x1d6e6:'\u03b5', ++0x1d6e7:'\u03b6', 0x1d6e8:'\u03b7', 0x1d6e9:'\u03b8', 0x1d6ea:'\u03b9', ++0x1d6eb:'\u03ba', 0x1d6ec:'\u03bb', 0x1d6ed:'\u03bc', 0x1d6ee:'\u03bd', ++0x1d6ef:'\u03be', 0x1d6f0:'\u03bf', 0x1d6f1:'\u03c0', 0x1d6f2:'\u03c1', ++0x1d6f3:'\u03b8', 0x1d6f4:'\u03c3', 0x1d6f5:'\u03c4', 0x1d6f6:'\u03c5', ++0x1d6f7:'\u03c6', 0x1d6f8:'\u03c7', 0x1d6f9:'\u03c8', 0x1d6fa:'\u03c9', ++0x1d70d:'\u03c3', 0x1d71c:'\u03b1', 0x1d71d:'\u03b2', 0x1d71e:'\u03b3', ++0x1d71f:'\u03b4', 0x1d720:'\u03b5', 0x1d721:'\u03b6', 0x1d722:'\u03b7', ++0x1d723:'\u03b8', 0x1d724:'\u03b9', 0x1d725:'\u03ba', 0x1d726:'\u03bb', ++0x1d727:'\u03bc', 0x1d728:'\u03bd', 0x1d729:'\u03be', 0x1d72a:'\u03bf', ++0x1d72b:'\u03c0', 0x1d72c:'\u03c1', 0x1d72d:'\u03b8', 0x1d72e:'\u03c3', ++0x1d72f:'\u03c4', 0x1d730:'\u03c5', 0x1d731:'\u03c6', 0x1d732:'\u03c7', ++0x1d733:'\u03c8', 0x1d734:'\u03c9', 0x1d747:'\u03c3', 0x1d756:'\u03b1', ++0x1d757:'\u03b2', 0x1d758:'\u03b3', 0x1d759:'\u03b4', 0x1d75a:'\u03b5', ++0x1d75b:'\u03b6', 0x1d75c:'\u03b7', 0x1d75d:'\u03b8', 0x1d75e:'\u03b9', ++0x1d75f:'\u03ba', 0x1d760:'\u03bb', 0x1d761:'\u03bc', 0x1d762:'\u03bd', ++0x1d763:'\u03be', 0x1d764:'\u03bf', 0x1d765:'\u03c0', 0x1d766:'\u03c1', ++0x1d767:'\u03b8', 0x1d768:'\u03c3', 0x1d769:'\u03c4', 0x1d76a:'\u03c5', ++0x1d76b:'\u03c6', 0x1d76c:'\u03c7', 0x1d76d:'\u03c8', 0x1d76e:'\u03c9', ++0x1d781:'\u03c3', 0x1d790:'\u03b1', 0x1d791:'\u03b2', 0x1d792:'\u03b3', ++0x1d793:'\u03b4', 0x1d794:'\u03b5', 0x1d795:'\u03b6', 0x1d796:'\u03b7', ++0x1d797:'\u03b8', 0x1d798:'\u03b9', 0x1d799:'\u03ba', 0x1d79a:'\u03bb', ++0x1d79b:'\u03bc', 0x1d79c:'\u03bd', 0x1d79d:'\u03be', 0x1d79e:'\u03bf', ++0x1d79f:'\u03c0', 0x1d7a0:'\u03c1', 0x1d7a1:'\u03b8', 0x1d7a2:'\u03c3', ++0x1d7a3:'\u03c4', 0x1d7a4:'\u03c5', 0x1d7a5:'\u03c6', 0x1d7a6:'\u03c7', ++0x1d7a7:'\u03c8', 0x1d7a8:'\u03c9', 0x1d7bb:'\u03c3', 0x1e900:'\U0001e900', ++0x1e901:'\U0001e901', 0x1e902:'\U0001e902', 0x1e903:'\U0001e903', 0x1e904:'\U0001e904', ++0x1e905:'\U0001e905', 0x1e906:'\U0001e906', 0x1e907:'\U0001e907', 0x1e908:'\U0001e908', ++0x1e909:'\U0001e909', 0x1e90a:'\U0001e90a', 0x1e90b:'\U0001e90b', 0x1e90c:'\U0001e90c', ++0x1e90d:'\U0001e90d', 0x1e90e:'\U0001e90e', 0x1e90f:'\U0001e90f', 0x1e910:'\U0001e910', ++0x1e911:'\U0001e911', 0x1e912:'\U0001e912', 0x1e913:'\U0001e913', 0x1e914:'\U0001e914', ++0x1e915:'\U0001e915', 0x1e916:'\U0001e916', 0x1e917:'\U0001e917', 0x1e918:'\U0001e918', ++0x1e919:'\U0001e919', 0x1e91a:'\U0001e91a', 0x1e91b:'\U0001e91b', 0x1e91c:'\U0001e91c', ++0x1e91d:'\U0001e91d', 0x1e91e:'\U0001e91e', 0x1e91f:'\U0001e91f', 0x1e920:'\U0001e920', ++0x1e921:'\U0001e921', } + + def map_table_b3(code): + r = b3_exceptions.get(ord(code)) +@@ -194,9 +371,9 @@ def map_table_b3(code): + + def map_table_b2(a): + al = map_table_b3(a) +- b = unicodedata.normalize("NFKC", al) ++ b = unicodedata_320.normalize("NFKC", al) + bl = "".join([map_table_b3(ch) for ch in b]) +- c = unicodedata.normalize("NFKC", bl) ++ c = unicodedata_320.normalize("NFKC", bl) + if b != c: + return c + else: +@@ -208,29 +385,29 @@ def in_table_c11(code): + + + def in_table_c12(code): +- return unicodedata.category(code) == "Zs" and code != " " ++ return unicodedata_320.category(code) == "Zs" and code != " " + + def in_table_c11_c12(code): +- return unicodedata.category(code) == "Zs" ++ return unicodedata_320.category(code) == "Zs" + + + def in_table_c21(code): +- return ord(code) < 128 and unicodedata.category(code) == "Cc" ++ return ord(code) < 128 and unicodedata_320.category(code) == "Cc" + + c22_specials = set([1757, 1807, 6158, 8204, 8205, 8232, 8233, 65279] + list(range(8288,8292)) + list(range(8298,8304)) + list(range(65529,65533)) + list(range(119155,119163))) + def in_table_c22(code): + c = ord(code) + if c < 128: return False +- if unicodedata.category(code) == "Cc": return True ++ if unicodedata_320.category(code) == "Cc": return True + return c in c22_specials + + def in_table_c21_c22(code): +- return unicodedata.category(code) == "Cc" or \ ++ return unicodedata_320.category(code) == "Cc" or \ + ord(code) in c22_specials + + + def in_table_c3(code): +- return unicodedata.category(code) == "Co" ++ return unicodedata_320.category(code) == "Co" + + + def in_table_c4(code): +@@ -241,7 +418,7 @@ def in_table_c4(code): + + + def in_table_c5(code): +- return unicodedata.category(code) == "Cs" ++ return unicodedata_320.category(code) == "Cs" + + + c6_set = set(range(65529,65534)) +@@ -265,8 +442,8 @@ def in_table_c9(code): + + + def in_table_d1(code): +- return unicodedata.bidirectional(code) in ("R","AL") ++ return unicodedata_320.bidirectional(code) in ("R","AL") + + + def in_table_d2(code): +- return unicodedata.bidirectional(code) == "L" ++ return unicodedata_320.bidirectional(code) == "L" +diff --git a/Lib/test/test_codecs.py b/Lib/test/test_codecs.py +index 9eca8fff62d84d2..ece2aaf5d583d07 100644 +--- a/Lib/test/test_codecs.py ++++ b/Lib/test/test_codecs.py +@@ -1620,6 +1620,15 @@ def test_builtin_encode(self): + self.assertEqual("pyth\xf6n.org".encode("idna"), b"xn--pythn-mua.org") + self.assertEqual("pyth\xf6n.org.".encode("idna"), b"xn--pythn-mua.org.") + ++ @support.subTests(['unicode', 'encoded'], [ ++ ('\N{CHEROKEE LETTER A}\N{CHEROKEE LETTER A}', b"xn--58da"), ++ ('\N{GEORGIAN CAPITAL LETTER AN}.', b"xn--7md."), ++ ('\N{CYRILLIC LETTER PALOCHKA}.example', b"xn--d5a.example"), ++ ('\N{ROMAN NUMERAL REVERSED ONE HUNDRED}.example.', b"xn--q5g.example."), ++ ]) ++ def test_new_unicode_case_folding(self, unicode, encoded): ++ self.assertEqual(unicode.encode("idna"), encoded) ++ + def test_builtin_encode_invalid(self): + for case, expected in self.invalid_encode_testcases: + with self.subTest(case=case, expected=expected): +diff --git a/Lib/test/test_unicodedata.py b/Lib/test/test_unicodedata.py +index 86995b62e3a41f7..02e8109c7d47dcd 100644 +--- a/Lib/test/test_unicodedata.py ++++ b/Lib/test/test_unicodedata.py +@@ -319,6 +319,15 @@ def test_bidirectional(self): + self.assertRaises(TypeError, self.db.bidirectional) + self.assertRaises(TypeError, self.db.bidirectional, 'xx') + ++ def test_bidirectional_unassigned(self): ++ self.assertEqual(self.db.bidirectional('\u0378'), '') ++ self.assertEqual(self.db.bidirectional('\u077F'), '' if self.old else 'AL') ++ self.assertEqual(self.db.bidirectional('\u20CF'), '') ++ self.assertEqual(self.db.bidirectional('\u0590'), '') ++ self.assertEqual(self.db.bidirectional('\uFFFF'), '') ++ self.assertEqual(self.db.bidirectional('\U0001FFFE'), '') ++ self.assertEqual(self.db.bidirectional('\U00010D01'), '' if self.old else 'AL') ++ + def test_decomposition(self): + self.assertEqual(self.db.decomposition('\uFFFE'),'') + self.assertEqual(self.db.decomposition('\u00bc'), ' 0031 2044 0034') +diff --git a/Misc/NEWS.d/next/Security/2026-08-06-11-43-20.gh-issue-155292.j4pHBO.rst b/Misc/NEWS.d/next/Security/2026-08-06-11-43-20.gh-issue-155292.j4pHBO.rst +new file mode 100644 +index 000000000000000..7a81a8ba1eef1cd +--- /dev/null ++++ b/Misc/NEWS.d/next/Security/2026-08-06-11-43-20.gh-issue-155292.j4pHBO.rst +@@ -0,0 +1,2 @@ ++Change the :mod:`stringprep` module and :mod:`encodings.idna` codec to not ++consider Unicode codepoint attributes beyond those defined in :rfc:`3454`. +diff --git a/Modules/unicodedata_db.h b/Modules/unicodedata_db.h +index 3e210863448b788..ed4b0eea9a6c590 100644 +--- a/Modules/unicodedata_db.h ++++ b/Modules/unicodedata_db.h +@@ -1,4 +1,4 @@ +-/* this file was generated by ./Tools/unicode/makeunicodedata.py 3.3 */ ++/* this file was generated by Tools/unicode/makeunicodedata.py 3.3 */ + + #define UNIDATA_VERSION "15.1.0" + /* a list of unique database records */ +diff --git a/Modules/unicodename_db.h b/Modules/unicodename_db.h +index a6fc2627b7e061e..bb5ec748f159e6f 100644 +--- a/Modules/unicodename_db.h ++++ b/Modules/unicodename_db.h +@@ -1,4 +1,4 @@ +-/* this file was generated by ./Tools/unicode/makeunicodedata.py 3.3 */ ++/* this file was generated by Tools/unicode/makeunicodedata.py 3.3 */ + + #define NAME_MAXLEN 256 + +diff --git a/Objects/unicodetype_db.h b/Objects/unicodetype_db.h +index e6dbeffbe2aa3ec..39a567dc46e89a8 100644 +--- a/Objects/unicodetype_db.h ++++ b/Objects/unicodetype_db.h +@@ -1,4 +1,4 @@ +-/* this file was generated by ./Tools/unicode/makeunicodedata.py 3.3 */ ++/* this file was generated by Tools/unicode/makeunicodedata.py 3.3 */ + + /* a list of unique character type descriptors */ + const _PyUnicode_TypeRecord _PyUnicode_TypeRecords[] = { +diff --git a/Tools/unicode/makeunicodedata.py b/Tools/unicode/makeunicodedata.py +index 4a23d7f51719dfe..181d4d07b52ac23 100644 +--- a/Tools/unicode/makeunicodedata.py ++++ b/Tools/unicode/makeunicodedata.py +@@ -28,6 +28,7 @@ + + import dataclasses + import os ++import subprocess + import sys + import zipfile + +@@ -126,6 +127,7 @@ def maketables(trace=0): + makeunicodename(unicode, trace) + makeunicodedata(unicode, trace) + makeunicodetype(unicode, trace) ++ makestringprep() + + + # -------------------------------------------------------------------- +@@ -711,6 +713,19 @@ def makeunicodename(unicode, trace): + fprint(' "%s",' % prefix) + fprint('};') + ++ ++def makestringprep(): ++ FILE = "Lib/stringprep.py" ++ ++ print("--- Preparing", FILE, "...") ++ ++ MKSTRINGPREP = "Tools/unicode/mkstringprep.py" ++ ++ with open(FILE, "w") as f: ++ f.truncate() ++ subprocess.check_call([sys.executable, MKSTRINGPREP], stdout=f) ++ ++ + def merge_old_version(version, new, old): + # Changes to exclusion file not implemented yet + if old.exclusions != new.exclusions: +diff --git a/Tools/unicode/mkstringprep.py b/Tools/unicode/mkstringprep.py +index 427188389a3b87c..9740338fef9d3d4 100644 +--- a/Tools/unicode/mkstringprep.py ++++ b/Tools/unicode/mkstringprep.py +@@ -1,15 +1,20 @@ + import re +-from unicodedata import ucd_3_2_0 as unicodedata ++import os ++import unicodedata as unicodedata_current ++from unicodedata import ucd_3_2_0 as unicodedata_320 ++ ++FILENAME = "Tools/unicode/data/rfc3454.txt" ++URL = "https://www.rfc-editor.org/rfc/rfc3454.txt" + + def gen_category(cats): + for i in range(0, 0x110000): +- if unicodedata.category(chr(i)) in cats: +- yield(i) ++ if unicodedata_320.category(chr(i)) in cats: ++ yield i + + def gen_bidirectional(cats): + for i in range(0, 0x110000): +- if unicodedata.bidirectional(chr(i)) in cats: +- yield(i) ++ if unicodedata_320.bidirectional(chr(i)) in cats: ++ yield i + + def compact_set(l): + single = [] +@@ -47,8 +52,16 @@ def compact_set(l): + + ############## Read the tables in the RFC ####################### + +-with open("rfc3454.txt") as f: +- data = f.readlines() ++try: ++ data_file = open(FILENAME, encoding='utf-8') ++except FileNotFoundError: ++ import urllib.request ++ os.makedirs(os.path.dirname(FILENAME), exist_ok=True) ++ urllib.request.urlretrieve(URL, filename=FILENAME) ++ data_file = open(FILENAME, encoding='utf-8') ++ ++with data_file: ++ data = data_file.readlines() + + tables = [] + curname = None +@@ -116,10 +129,18 @@ def compact_set(l): + and mappings, for which a mapping function is provided. + \"\"\" + +-from unicodedata import ucd_3_2_0 as unicodedata ++# This check asserts that mkstringprep.py has been run ++# when unicodedata is modified to ensure conformant behavior. ++import unicodedata ++""") ++ ++print("assert unicodedata.unidata_version == %r" % (unicodedata_current.unidata_version,)) ++ ++print(""" ++from unicodedata import ucd_3_2_0 as unicodedata_320 + """) + +-print("assert unicodedata.unidata_version == %r" % (unicodedata.unidata_version,)) ++print("assert unicodedata_320.unidata_version == %r" % (unicodedata_320.unidata_version,)) + + # A.1 is the table of unassigned characters + # XXX Plane 15 PUA is listed as unassigned in Python. +@@ -139,7 +160,7 @@ def compact_set(l): + + print(""" + def in_table_a1(code): +- if unicodedata.category(code) != 'Cn': return False ++ if unicodedata_320.category(code) != 'Cn': return False + c = ord(code) + if 0xFDD0 <= c < 0xFDF0: return False + return (c & 0xFFFF) not in (0xFFFE, 0xFFFF) +@@ -172,21 +193,33 @@ def in_table_b1(code): + + # B.3 is mostly Python's .lower, except for a number + # of special cases, e.g. considering canonical forms. ++# To enforce Unicode 3.2.0 behavior of .lower instead of ++# whatever Unicode version is included with Python we ++# add unassigned or newly case-folding codepoints to ++# the exception map, too. + + b3_exceptions = {} + + for k,v in table_b2.items(): + if list(map(ord, chr(k).lower())) != v: + b3_exceptions[k] = "".join(map(chr,v)) ++for cp in range(0x110000): ++ ch = chr(cp) ++ # Assigned in current Unicode version ++ # and supports case folding, but not ++ # explicitly in B.2 or B.3 tables. ++ if (unicodedata_current.category(ch) != "Cn" ++ and ch.lower() != ch ++ and cp not in table_b2 ++ and cp not in table_b3): ++ b3_exceptions[cp] = ch # Identity. + + b3 = sorted(b3_exceptions.items()) + + print(""" + b3_exceptions = {""") + for i, kv in enumerate(b3): +- print("0x%x:%a," % kv, end=' ') +- if i % 4 == 3: +- print() ++ print("0x%x:%a," % kv, end='\n' if i % 4 == 3 else ' ') + print("}") + + print(""" +@@ -207,9 +240,9 @@ def map_table_b3(code): + + def map_table_b2(a): + al = map_table_b3(a) +- b = unicodedata.normalize("NFKC", al) ++ b = unicodedata_320.normalize("NFKC", al) + bl = "".join([map_table_b3(ch) for ch in b]) +- c = unicodedata.normalize("NFKC", bl) ++ c = unicodedata_320.normalize("NFKC", bl) + if b != c: + return c + else: +@@ -226,9 +259,9 @@ def map_table_b2(a): + print(""" + def map_table_b2(a): + al = map_table_b3(a) +- b = unicodedata.normalize("NFKC", al) ++ b = unicodedata_320.normalize("NFKC", al) + bl = "".join([map_table_b3(ch) for ch in b]) +- c = unicodedata.normalize("NFKC", bl) ++ c = unicodedata_320.normalize("NFKC", bl) + if b != c: + return c + else: +@@ -251,16 +284,16 @@ def in_table_c11(code): + del tables[0] + assert name == "C.1.2" + +-# table = set(table.keys()) +-# Zs = set(gen_category(["Zs"])) - {0x20} +-# assert Zs == table ++table = set(table.keys()) ++Zs = set(gen_category(["Zs"])) - {0x20} ++assert Zs == table + + print(""" + def in_table_c12(code): +- return unicodedata.category(code) == "Zs" and code != " " ++ return unicodedata_320.category(code) == "Zs" and code != " " + + def in_table_c11_c12(code): +- return unicodedata.category(code) == "Zs" ++ return unicodedata_320.category(code) == "Zs" + """) + + # C.2.1 ASCII control characters +@@ -275,7 +308,7 @@ def in_table_c11_c12(code): + + print(""" + def in_table_c21(code): +- return ord(code) < 128 and unicodedata.category(code) == "Cc" ++ return ord(code) < 128 and unicodedata_320.category(code) == "Cc" + """) + + # C.2.2 Non-ASCII control characters. It also includes +@@ -295,11 +328,11 @@ def in_table_c21(code): + def in_table_c22(code): + c = ord(code) + if c < 128: return False +- if unicodedata.category(code) == "Cc": return True ++ if unicodedata_320.category(code) == "Cc": return True + return c in c22_specials + + def in_table_c21_c22(code): +- return unicodedata.category(code) == "Cc" or \\ ++ return unicodedata_320.category(code) == "Cc" or \\ + ord(code) in c22_specials + """) + +@@ -313,7 +346,7 @@ def in_table_c21_c22(code): + + print(""" + def in_table_c3(code): +- return unicodedata.category(code) == "Co" ++ return unicodedata_320.category(code) == "Co" + """) + + # C.4 Non-character code points, xFFFE, xFFFF +@@ -346,7 +379,7 @@ def in_table_c4(code): + + print(""" + def in_table_c5(code): +- return unicodedata.category(code) == "Cs" ++ return unicodedata_320.category(code) == "Cs" + """) + + # C.6 Inappropriate for plain text +@@ -411,7 +444,7 @@ def in_table_c9(code): + + print(""" + def in_table_d1(code): +- return unicodedata.bidirectional(code) in ("R","AL") ++ return unicodedata_320.bidirectional(code) in ("R","AL") + """) + + # D.2 Characters with bidirectional property "L" +@@ -424,5 +457,5 @@ def in_table_d1(code): + + print(""" + def in_table_d2(code): +- return unicodedata.bidirectional(code) == "L" +-""") ++ return unicodedata_320.bidirectional(code) == "L" ++""", end="") diff --git a/deps/cpython/SECURITY_PATCHES.md b/deps/cpython/SECURITY_PATCHES.md index 3eb074005d8f..8919a2b893bc 100644 --- a/deps/cpython/SECURITY_PATCHES.md +++ b/deps/cpython/SECURITY_PATCHES.md @@ -9,12 +9,13 @@ CVEs. These patches do not change VEX or exception decisions. | --- | --- | --- | | CVE-2026-15806 | [3.13 commit a2773a34](https://github.com/python/cpython/commit/a2773a34183b7d94a243bb98fd658926cc5348ce) | None | | CVE-2026-19672 | [3.13 commit c7979f3a](https://github.com/python/cpython/commit/c7979f3a819011a3222bd16e671264b1e34282cb) | None; test hunk has a line offset | +| CVE-2026-17084 | [3.13 commit c28b121a](https://github.com/python/cpython/commit/c28b121a4f0b975937c8b5a1b4934bb361d84296) | None; preserves the 3.13-specific generated Unicode tables | | CVE-2025-15367 | [commit b234a2b6](https://github.com/python/cpython/commit/b234a2b67539f787e191d2ef19a7cbdce32874e7) | Test import context accounts for 3.13's existing `ExtraAssertions` import; production fix and test logic unchanged | Each patch retains upstream regression tests. With a patched CPython source build, -run `./python -m test test_urllib2 test_tarfile test_poplib`. The agent image CI +run `./python -m test test_urllib2 test_tarfile test_poplib test_codecs test_unicodedata test_stringprep`. The agent image CI also runs `scripts/test_embedded_python_security.py` with the actual packaged -interpreter on AMD64 and ARM64. This checks the three security boundaries and +interpreter on AMD64 and ARM64. This checks the four security boundaries and valid operations without external network access. Image vulnerability and secret scans remain separate steps. Remove a backport only when an upstream release includes it and the packaged-interpreter tests pass. @@ -26,12 +27,10 @@ Remaining rows as of 2026-09-18: compatibility correction](https://github.com/python/cpython/pull/157180). Reconsider when the 3.13 backport and applicable compatibility correction are accepted upstream; the scanner's 3.15.0rc2 lead is not a compatible 3.13 bump. -- **CVE-2026-17084:** retain the existing no-stable-3.13-fix assessment; the known - scanner lead is 3.15.0rc2. Reconsider on a supported 3.13 fix/backport or changed - upstream evidence in [issue 155292](https://github.com/python/cpython/issues/155292). -- **CVE-2026-87910:** retain the existing no-scanner-fix assessment. Reconsider - when [issue 157265](https://github.com/python/cpython/issues/157265) provides a - supported 3.13 correction or scanner data changes. +- **CVE-2026-87910:** the [3.13 tarfile backport](https://github.com/python/cpython/pull/157308) + remains open. Reconsider after that supported-branch correction and the applicable + [Windows test correction](https://github.com/python/cpython/pull/157334) are + accepted upstream, or when scanner fix evidence changes. The latest upstream 3.13 tag checked was 3.13.15. No runtime upgrade to 3.15, applicability decision, suppression, or exception renewal is part of this work. diff --git a/deps/cpython/cpython.MODULE.bazel b/deps/cpython/cpython.MODULE.bazel index 8f3d294646f1..c45caba06db9 100644 --- a/deps/cpython/cpython.MODULE.bazel +++ b/deps/cpython/cpython.MODULE.bazel @@ -15,6 +15,7 @@ http_archive( "//deps/cpython:0003-CVE-2026-15806-urllib-credential-scheme.patch", "//deps/cpython:0004-CVE-2026-19672-tarfile-path.patch", "//deps/cpython:0005-CVE-2025-15367-poplib-commands.patch", + "//deps/cpython:0006-CVE-2026-17084-stringprep-unicode.patch", ], sha256 = "c28d9d213c09b5b5ab2c29812950e12f746999e099b82894231be954b26baed9", strip_prefix = "Python-{}".format(PYTHON_VERSION), diff --git a/releasenotes/notes/python-security-backports-35310940870.yaml b/releasenotes/notes/python-security-backports-35310940870.yaml index e136b48838c5..fb0b7bd44d7a 100644 --- a/releasenotes/notes/python-security-backports-35310940870.yaml +++ b/releasenotes/notes/python-security-backports-35310940870.yaml @@ -1,8 +1,10 @@ --- security: - | - Backport Python fixes for CVE-2026-15806, CVE-2026-19672 and CVE-2025-15367. + Backport Python fixes for CVE-2026-15806, CVE-2026-19672, CVE-2025-15367 + and CVE-2026-17084. HTTP authentication credentials are scoped by URL scheme, tar extraction filters avoid creating directories outside the destination, and POP3 - commands reject control characters. The embedded Python version remains + commands reject control characters. IDNA string preparation uses the + Unicode 3.2 case-folding rules required by RFC 3454. The Python version remains 3.13.15 with these source patches applied. diff --git a/scripts/test_embedded_python_security.py b/scripts/test_embedded_python_security.py index fca4039fbc56..21bb888d869e 100644 --- a/scripts/test_embedded_python_security.py +++ b/scripts/test_embedded_python_security.py @@ -3,6 +3,7 @@ import hashlib import io import poplib +import stringprep import sys import tarfile import tempfile @@ -51,6 +52,18 @@ def test_tar_filters_do_not_create_directories_outside_destination(self): self.assertEqual((destination / "sub/file").read_bytes(), content) self.assertFalse((Path(root) / "outside").exists()) + def test_idna_uses_unicode_3_2_case_folding(self): + cases = ( + ("\N{CHEROKEE LETTER A}\N{CHEROKEE LETTER A}", b"xn--58da"), + ("\N{GEORGIAN CAPITAL LETTER AN}.", b"xn--7md."), + ("\N{CYRILLIC LETTER PALOCHKA}.example", b"xn--d5a.example"), + ("\N{ROMAN NUMERAL REVERSED ONE HUNDRED}.example.", b"xn--q5g.example."), + ) + for name, encoded in cases: + with self.subTest(name=name): + self.assertEqual(name.encode("idna"), encoded) + self.assertEqual("example.invalid".encode("idna"), b"example.invalid") + def test_pop3_rejects_control_characters_before_sending(self): client = poplib.POP3.__new__(poplib.POP3) client._debugging = 0 @@ -67,7 +80,7 @@ def test_pop3_rejects_control_characters_before_sending(self): if __name__ == "__main__": print(f"Embedded interpreter: {sys.executable}; version: {sys.version}", flush=True) - for module in (urllib.request, tarfile, poplib): + for module in (urllib.request, tarfile, poplib, stringprep): source = Path(module.__file__) print(f"{module.__name__}: {source}; sha256={hashlib.sha256(source.read_bytes()).hexdigest()}", flush=True) unittest.main(verbosity=2) From d258823aec7b1d614c5f65473278e7552b8abff4 Mon Sep 17 00:00:00 2001 From: "SUSE Observability AI (POC)" Date: Fri, 18 Sep 2026 09:48:31 +0000 Subject: [PATCH 3/6] fix(python): backport ZIP bounds and tar link filter corrections --- .../0007-CVE-2026-15310-zipfile-bounds.patch | 178 ++++++++++++++++++ .../0008-CVE-2026-15310-python313-tests.patch | 27 +++ ...026-15310-decompressor-compatibility.patch | 161 ++++++++++++++++ .../0010-CVE-2026-87910-tarfile-filter.patch | 93 +++++++++ .../0011-CVE-2026-87910-windows-test.patch | 46 +++++ deps/cpython/SECURITY_PATCHES.md | 33 ++-- deps/cpython/cpython.MODULE.bazel | 5 + ...python-security-backports-35310940870.yaml | 7 +- scripts/test_embedded_python_security.py | 50 ++++- 9 files changed, 580 insertions(+), 20 deletions(-) create mode 100644 deps/cpython/0007-CVE-2026-15310-zipfile-bounds.patch create mode 100644 deps/cpython/0008-CVE-2026-15310-python313-tests.patch create mode 100644 deps/cpython/0009-CVE-2026-15310-decompressor-compatibility.patch create mode 100644 deps/cpython/0010-CVE-2026-87910-tarfile-filter.patch create mode 100644 deps/cpython/0011-CVE-2026-87910-windows-test.patch diff --git a/deps/cpython/0007-CVE-2026-15310-zipfile-bounds.patch b/deps/cpython/0007-CVE-2026-15310-zipfile-bounds.patch new file mode 100644 index 000000000000..88f84b2801d0 --- /dev/null +++ b/deps/cpython/0007-CVE-2026-15310-zipfile-bounds.patch @@ -0,0 +1,178 @@ +From 7a2b7388233ea0c647168c5792dc31f3051fe177 Mon Sep 17 00:00:00 2001 +From: Petr Viktorin +Date: Mon, 31 Aug 2026 21:04:04 +0200 +Subject: [PATCH 1/3] [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 +Co-authored-by: tonghuaroot +--- + Lib/test/test_zipfile/test_core.py | 42 +++++++++++++++++++ + Lib/zipfile/__init__.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/test_core.py b/Lib/test/test_zipfile/test_core.py +index 57bd71481a3762..b04ba512b8e34e 100644 +--- a/Lib/test/test_zipfile/test_core.py ++++ b/Lib/test/test_zipfile/test_core.py +@@ -2619,6 +2619,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/__init__.py b/Lib/zipfile/__init__.py +index 37555d32d99730..8753c07f61e35e 100644 +--- a/Lib/zipfile/__init__.py ++++ b/Lib/zipfile/__init__.py +@@ -726,7 +726,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: +@@ -742,7 +751,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 + +@@ -802,6 +811,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: +@@ -1102,8 +1118,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 +@@ -1116,8 +1139,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 00000000000000..4e49ad5ce8fa00 +--- /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. + diff --git a/deps/cpython/0008-CVE-2026-15310-python313-tests.patch b/deps/cpython/0008-CVE-2026-15310-python313-tests.patch new file mode 100644 index 000000000000..461315a678f8 --- /dev/null +++ b/deps/cpython/0008-CVE-2026-15310-python313-tests.patch @@ -0,0 +1,27 @@ +From d53a41e8cf0148af073c46abc36c2969adcb70c2 Mon Sep 17 00:00:00 2001 +From: Petr Viktorin +Date: Thu, 3 Sep 2026 16:39:39 +0200 +Subject: [PATCH 2/3] Remove zstd test (3.14+) + +--- + Lib/test/test_zipfile/test_core.py | 6 ------ + 1 file changed, 6 deletions(-) + +diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py +index b04ba512b8e34e..4e4a74b2df42ec 100644 +--- a/Lib/test/test_zipfile/test_core.py ++++ b/Lib/test/test_zipfile/test_core.py +@@ -2655,12 +2655,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.""" + diff --git a/deps/cpython/0009-CVE-2026-15310-decompressor-compatibility.patch b/deps/cpython/0009-CVE-2026-15310-decompressor-compatibility.patch new file mode 100644 index 000000000000..45f449d07552 --- /dev/null +++ b/deps/cpython/0009-CVE-2026-15310-decompressor-compatibility.patch @@ -0,0 +1,161 @@ +From f3f5234facd2ba491b3bff9e0ac9bd54378314ce Mon Sep 17 00:00:00 2001 +From: "Miss Islington (bot)" + <31488909+miss-islington@users.noreply.github.com> +Date: Wed, 16 Sep 2026 06:52:44 -0700 +Subject: [PATCH 3/3] gh-156002: Keep reading through monkey-patched zipfile + decompressors (GH-157180) (GH-157557) + +(cherry picked from commit f507e6946a3194e83e1d7b8ee6e14567175e46de) + +Co-authored-by: Petr Viktorin +Co-authored-by: rasmusfaber +--- + Lib/test/test_zipfile/test_core.py | 68 +++++++++++++++++++ + Lib/zipfile/__init__.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/test_core.py b/Lib/test/test_zipfile/test_core.py +index 4e4a74b2df42ec..05e39faa104555 100644 +--- a/Lib/test/test_zipfile/test_core.py ++++ b/Lib/test/test_zipfile/test_core.py +@@ -2655,6 +2655,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/__init__.py b/Lib/zipfile/__init__.py +index 8753c07f61e35e..a64f831ecddac3 100644 +--- a/Lib/zipfile/__init__.py ++++ b/Lib/zipfile/__init__.py +@@ -727,7 +727,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. +@@ -811,13 +811,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: +@@ -1123,7 +1116,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'' +@@ -1142,10 +1135,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 00000000000000..a21386803cca0f +--- /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`. diff --git a/deps/cpython/0010-CVE-2026-87910-tarfile-filter.patch b/deps/cpython/0010-CVE-2026-87910-tarfile-filter.patch new file mode 100644 index 000000000000..9e16635435ef --- /dev/null +++ b/deps/cpython/0010-CVE-2026-87910-tarfile-filter.patch @@ -0,0 +1,93 @@ +From 05f187c81a6e2d90d627d96f2ad0b6724f044aef Mon Sep 17 00:00:00 2001 +From: Petr Viktorin +Date: Fri, 11 Sep 2026 14:19:35 +0200 +Subject: [PATCH 1/2] gh-157265: tarfile: Honor None result of filter for link + fallbacks (GH-157266) + +(cherry picked from commit fb2f0bbc3b35264f09cc2cb2934b7987527a6bc2) + +Co-authored-by: Petr Viktorin +Co-authored-by: Stan Ulbrych +--- + Lib/tarfile.py | 4 ++- + Lib/test/test_tarfile.py | 31 +++++++++++++++++-- + ...-09-10-13-38-11.gh-issue-157265.-vYuMp.rst | 3 ++ + 3 files changed, 34 insertions(+), 4 deletions(-) + create mode 100644 Misc/NEWS.d/next/Security/2026-09-10-13-38-11.gh-issue-157265.-vYuMp.rst + +diff --git a/Lib/tarfile.py b/Lib/tarfile.py +index e68d7bbf3afb79c..5bc1aac8bec2afb 100755 +--- a/Lib/tarfile.py ++++ b/Lib/tarfile.py +@@ -2764,9 +2764,11 @@ def makelink_with_filter(self, tarinfo, targetpath, + "makelink_with_filter: if filter_function is not None, " + + "extraction_root must also not be None") + try: +- filter_function( ++ filtered = filter_function( + unfiltered.replace(name=tarinfo.name, deep=False), + extraction_root) ++ if filtered is None: ++ return + filtered = filter_function(unfiltered, extraction_root) + except _FILTER_ERRORS as cause: + raise LinkFallbackError(tarinfo, unfiltered.name) from cause +diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py +index 34e9fb587725cb3..4fc06621469eed8 100644 +--- a/Lib/test/test_tarfile.py ++++ b/Lib/test/test_tarfile.py +@@ -4418,9 +4418,15 @@ def test_sneaky_hardlink_fallback(self): + 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/t/dummy") +- self.expect_file("b/") +- self.expect_file("c/") ++ if filter == 'tar': ++ self.expect_exception( ++ tarfile.LinkFallbackError, ++ "link 'boom' would be extracted as a copy of " ++ + "'c/escape', which was rejected") ++ else: ++ self.expect_file("a/t/dummy") ++ self.expect_file("b/") ++ self.expect_file("c/") + else: + self.expect_file("a/t/dummy") + self.expect_file("b/") +@@ -4617,6 +4623,25 @@ def testing_filter(member, path): + if os_helper.can_chmod(): + self.assertFalse(path.stat().st_mode & stat.S_IWUSR) + ++ @symlink_test ++ def test_extract_filters_target_none(self): ++ # Test that when extract() falls back to extracting (rather than ++ # linking) a hardlink target, the member is skipped if the filter ++ # returns None. ++ with ArchiveMaker() as arc: ++ arc.add('a/b/s', symlink_to='../escape') ++ arc.add('q', hardlink_to='a/b/s') ++ def filter_unsafe_members(member, path): ++ try: ++ return tarfile.data_filter(member, path) ++ except tarfile.FilterError as error: ++ return None ++ with self.check_context(arc.open(), filter_unsafe_members): ++ if os_helper.can_symlink(): ++ self.expect_file('a/b/s', symlink_to='../escape') ++ else: ++ self.expect_file('a/b/') # symlink is not extracted ++ + def test_link_fallback_normalizes(self): + # Make sure hardlink fallbacks work for non-normalized paths for all + # filters +diff --git a/Misc/NEWS.d/next/Security/2026-09-10-13-38-11.gh-issue-157265.-vYuMp.rst b/Misc/NEWS.d/next/Security/2026-09-10-13-38-11.gh-issue-157265.-vYuMp.rst +new file mode 100644 +index 000000000000000..ba27e47f734bfb1 +--- /dev/null ++++ b/Misc/NEWS.d/next/Security/2026-09-10-13-38-11.gh-issue-157265.-vYuMp.rst +@@ -0,0 +1,3 @@ ++In :mod:`tarfile`, when extracting a link falls back to extracting a member ++of the archive, skip the member when the filter function returns None when ++called with the extracted member's name replaced with the link's. + diff --git a/deps/cpython/0011-CVE-2026-87910-windows-test.patch b/deps/cpython/0011-CVE-2026-87910-windows-test.patch new file mode 100644 index 000000000000..06f7b52060b4 --- /dev/null +++ b/deps/cpython/0011-CVE-2026-87910-windows-test.patch @@ -0,0 +1,46 @@ +From f93473e550cc2e75f3ebc5cc890ca1af318efe36 Mon Sep 17 00:00:00 2001 +From: Petr Viktorin +Date: Fri, 11 Sep 2026 19:54:41 +0200 +Subject: [PATCH 2/2] gh-157265: Adjust test for Windows (GH-157334) + +gh-157266: Adjust test for Windows + +On Windows (no symlinks, no hardlinks), the behaviour is +the same as without the fix in GH-157266: +- a/t/dummy is extracted +- b/ is extracted +- c/ is *not* created (the target, a/t, is not in the archive) +- c/escape: c/ is created; escape is skipped (target, + c/../../link_here, is not in archive) +- c is not recreated as a directory +- boom is not created (target is c/escape, which falls back to + ..\..\link_here, which does not exist in archive) +--- + Lib/test/test_tarfile.py | 10 +++++----- + 1 file changed, 5 insertions(+), 5 deletions(-) + +diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py +index 4fc06621469eed8..726ecff80124f8e 100644 +--- a/Lib/test/test_tarfile.py ++++ b/Lib/test/test_tarfile.py +@@ -4418,15 +4418,15 @@ def test_sneaky_hardlink_fallback(self): + for filter in 'tar', 'fully_trusted': + with self.subTest(filter), self.check_context(arc.open(), filter): + if not os_helper.can_symlink(): +- if filter == 'tar': ++ if filter == 'fully_trusted' or sys.platform == "win32": ++ self.expect_file("a/t/dummy") ++ self.expect_file("b/") ++ self.expect_file("c/") ++ else: + self.expect_exception( + tarfile.LinkFallbackError, + "link 'boom' would be extracted as a copy of " + + "'c/escape', which was rejected") +- else: +- self.expect_file("a/t/dummy") +- self.expect_file("b/") +- self.expect_file("c/") + else: + self.expect_file("a/t/dummy") + self.expect_file("b/") diff --git a/deps/cpython/SECURITY_PATCHES.md b/deps/cpython/SECURITY_PATCHES.md index 8919a2b893bc..245a852d879f 100644 --- a/deps/cpython/SECURITY_PATCHES.md +++ b/deps/cpython/SECURITY_PATCHES.md @@ -9,29 +9,32 @@ CVEs. These patches do not change VEX or exception decisions. | --- | --- | --- | | CVE-2026-15806 | [3.13 commit a2773a34](https://github.com/python/cpython/commit/a2773a34183b7d94a243bb98fd658926cc5348ce) | None | | CVE-2026-19672 | [3.13 commit c7979f3a](https://github.com/python/cpython/commit/c7979f3a819011a3222bd16e671264b1e34282cb) | None; test hunk has a line offset | +| CVE-2026-15310 | [3.13 PR156738](https://github.com/python/cpython/pull/156738), commits `7a2b7388233ea0c647168c5792dc31f3051fe177`, `d53a41e8cf0148af073c46abc36c2969adcb70c2`, `f3f5234facd2ba491b3bff9e0ac9bd54378314ce` | None; includes removal of unsupported Zstandard tests and the third-party decompressor compatibility correction | +| CVE-2026-87910 | [3.13 PR157308](https://github.com/python/cpython/pull/157308), commits `05f187c81a6e2d90d627d96f2ad0b6724f044aef`, `f93473e550cc2e75f3ebc5cc890ca1af318efe36` | None; includes the Windows regression-test correction | | CVE-2026-17084 | [3.13 commit c28b121a](https://github.com/python/cpython/commit/c28b121a4f0b975937c8b5a1b4934bb361d84296) | None; preserves the 3.13-specific generated Unicode tables | | CVE-2025-15367 | [commit b234a2b6](https://github.com/python/cpython/commit/b234a2b67539f787e191d2ef19a7cbdce32874e7) | Test import context accounts for 3.13's existing `ExtraAssertions` import; production fix and test logic unchanged | Each patch retains upstream regression tests. With a patched CPython source build, -run `./python -m test test_urllib2 test_tarfile test_poplib test_codecs test_unicodedata test_stringprep`. The agent image CI +run `./python -m test test_urllib2 test_tarfile test_poplib test_codecs test_unicodedata test_stringprep test_zipfile`. The agent image CI also runs `scripts/test_embedded_python_security.py` with the actual packaged -interpreter on AMD64 and ARM64. This checks the four security boundaries and +interpreter on AMD64 and ARM64. This checks the six security boundaries and valid operations without external network access. Image vulnerability and secret scans remain separate steps. Remove a backport only when an upstream release includes it and the packaged-interpreter tests pass. -Remaining rows as of 2026-09-18: +Upstream status checked 2026-09-18: -- **CVE-2026-15310:** the [3.13 zipfile backport](https://github.com/python/cpython/pull/156738) - remains open. The original fix also needed a [third-party decompressor - compatibility correction](https://github.com/python/cpython/pull/157180). - Reconsider when the 3.13 backport and applicable compatibility correction are - accepted upstream; the scanner's 3.15.0rc2 lead is not a compatible 3.13 bump. -- **CVE-2026-87910:** the [3.13 tarfile backport](https://github.com/python/cpython/pull/157308) - remains open. Reconsider after that supported-branch correction and the applicable - [Windows test correction](https://github.com/python/cpython/pull/157334) are - accepted upstream, or when scanner fix evidence changes. +The 3.13 zipfile and tar-link backport PRs remain open upstream. Their main-branch +fixes are merged. The copied zipfile series includes the compatibility correction +from [GH-157180](https://github.com/python/cpython/pull/157180); the tar-link series +includes the Windows test correction from +[GH-157334](https://github.com/python/cpython/pull/157334). Their open status is +retained for independent review; they are not represented as released Python +3.13 fixes. The other 3.13 backports are merged; POP3 is adapted from its merged +main-branch fix. Runtime regression tests are mandatory for all six fixes. -The latest upstream 3.13 tag checked was 3.13.15. No runtime upgrade to 3.15, -applicability decision, suppression, or exception renewal is part of this work. -PR511 and GO-2026-5932 remain outside this patch's scope. +The latest upstream 3.13 tag checked was 3.13.15. The 3.15.0rc2 scanner leads are +not used as a runtime upgrade. Version-only findings for these six CVEs may remain +until upstream release/scanner metadata or a separately approved applicability +decision accounts for the backports. No applicability decision, suppression, or +exception renewal is part of this work. PR511 and GO-2026-5932 remain outside scope. diff --git a/deps/cpython/cpython.MODULE.bazel b/deps/cpython/cpython.MODULE.bazel index c45caba06db9..6fc3c4d3d436 100644 --- a/deps/cpython/cpython.MODULE.bazel +++ b/deps/cpython/cpython.MODULE.bazel @@ -16,6 +16,11 @@ http_archive( "//deps/cpython:0004-CVE-2026-19672-tarfile-path.patch", "//deps/cpython:0005-CVE-2025-15367-poplib-commands.patch", "//deps/cpython:0006-CVE-2026-17084-stringprep-unicode.patch", + "//deps/cpython:0007-CVE-2026-15310-zipfile-bounds.patch", + "//deps/cpython:0008-CVE-2026-15310-python313-tests.patch", + "//deps/cpython:0009-CVE-2026-15310-decompressor-compatibility.patch", + "//deps/cpython:0010-CVE-2026-87910-tarfile-filter.patch", + "//deps/cpython:0011-CVE-2026-87910-windows-test.patch", ], sha256 = "c28d9d213c09b5b5ab2c29812950e12f746999e099b82894231be954b26baed9", strip_prefix = "Python-{}".format(PYTHON_VERSION), diff --git a/releasenotes/notes/python-security-backports-35310940870.yaml b/releasenotes/notes/python-security-backports-35310940870.yaml index fb0b7bd44d7a..5e204ce85163 100644 --- a/releasenotes/notes/python-security-backports-35310940870.yaml +++ b/releasenotes/notes/python-security-backports-35310940870.yaml @@ -1,10 +1,11 @@ --- security: - | - Backport Python fixes for CVE-2026-15806, CVE-2026-19672, CVE-2025-15367 - and CVE-2026-17084. + Backport Python fixes for CVE-2026-15806, CVE-2026-19672, CVE-2025-15367, + CVE-2026-17084, CVE-2026-15310 and CVE-2026-87910. HTTP authentication credentials are scoped by URL scheme, tar extraction filters avoid creating directories outside the destination, and POP3 commands reject control characters. IDNA string preparation uses the Unicode 3.2 case-folding rules required by RFC 3454. The Python version remains - 3.13.15 with these source patches applied. + 3.13.15 with these source patches applied. ZIP reads bound decompression + memory, and tar link fallbacks honor extraction-filter rejections. diff --git a/scripts/test_embedded_python_security.py b/scripts/test_embedded_python_security.py index 21bb888d869e..d8d92c729526 100644 --- a/scripts/test_embedded_python_security.py +++ b/scripts/test_embedded_python_security.py @@ -9,8 +9,9 @@ import tempfile import unittest import urllib.request +import zipfile from pathlib import Path -from unittest.mock import Mock +from unittest.mock import Mock, patch class EmbeddedPythonSecurityTests(unittest.TestCase): @@ -52,6 +53,51 @@ def test_tar_filters_do_not_create_directories_outside_destination(self): self.assertEqual((destination / "sub/file").read_bytes(), content) self.assertFalse((Path(root) / "outside").exists()) + def test_zipfile_small_reads_bound_decompression(self): + content = b"\0" * (4 * 1024 * 1024) + for compression in (zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA): + with self.subTest(compression=compression): + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w", compression=compression) as writer: + writer.writestr("content", content) + archive.seek(0) + with zipfile.ZipFile(archive) as reader, reader.open("content") as member: + first = member._read1(100) + self.assertLessEqual(len(first), member.MIN_READ_SIZE) + self.assertEqual(first + member.read(), content) + + def test_tar_link_fallback_honors_filter_rejection(self): + with tempfile.TemporaryDirectory() as destination: + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode="w") as writer: + symlink = tarfile.TarInfo("a/b/s") + symlink.type = tarfile.SYMTYPE + symlink.linkname = "../escape" + writer.addfile(symlink) + hardlink = tarfile.TarInfo("q") + hardlink.type = tarfile.LNKTYPE + hardlink.linkname = "a/b/s" + writer.addfile(hardlink) + rejected = [] + + def skip_unsafe(member, path): + try: + return tarfile.data_filter(member, path) + except tarfile.FilterError: + rejected.append(member.name) + return None + + archive.seek(0) + with ( + tarfile.open(fileobj=archive) as reader, + patch("tarfile.os.link", side_effect=OSError("Exercise link fallback")), + ): + reader.extractall(destination, filter=skip_unsafe) + self.assertIn("q", rejected) + self.assertTrue((Path(destination) / "a/b/s").is_symlink()) + self.assertFalse((Path(destination) / "q").is_symlink()) + self.assertFalse((Path(destination) / "q").exists()) + def test_idna_uses_unicode_3_2_case_folding(self): cases = ( ("\N{CHEROKEE LETTER A}\N{CHEROKEE LETTER A}", b"xn--58da"), @@ -80,7 +126,7 @@ def test_pop3_rejects_control_characters_before_sending(self): if __name__ == "__main__": print(f"Embedded interpreter: {sys.executable}; version: {sys.version}", flush=True) - for module in (urllib.request, tarfile, poplib, stringprep): + for module in (urllib.request, tarfile, poplib, stringprep, zipfile): source = Path(module.__file__) print(f"{module.__name__}: {source}; sha256={hashlib.sha256(source.read_bytes()).hexdigest()}", flush=True) unittest.main(verbosity=2) From cdcc37ce35c9afef52acf469bef91f57e8a59815 Mon Sep 17 00:00:00 2001 From: "SUSE Observability AI (POC)" Date: Fri, 18 Sep 2026 09:58:11 +0000 Subject: [PATCH 4/6] fix(build): validate Bazel Python inputs across omnibus cache restores --- deps/cpython/SECURITY_PATCHES.md | 4 ++++ omnibus/config/software/python3.rb | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/deps/cpython/SECURITY_PATCHES.md b/deps/cpython/SECURITY_PATCHES.md index 245a852d879f..4f6e2d4f31da 100644 --- a/deps/cpython/SECURITY_PATCHES.md +++ b/deps/cpython/SECURITY_PATCHES.md @@ -5,6 +5,10 @@ upstream fixes during the source build on every supported platform. The version string stays 3.13.15; version-only vulnerability scanners can still report these CVEs. These patches do not change VEX or exception decisions. +The Omnibus `python3` recipe uses `always_build true`: Omnibus does not fingerprint +Bazel patch inputs, so Bazel must validate its own cache even when the Python +version stays unchanged. + | CVE | Upstream source | Local adaptation | | --- | --- | --- | | CVE-2026-15806 | [3.13 commit a2773a34](https://github.com/python/cpython/commit/a2773a34183b7d94a243bb98fd658926cc5348ce) | None | diff --git a/omnibus/config/software/python3.rb b/omnibus/config/software/python3.rb index 3da70b38095d..9cf1246287ac 100644 --- a/omnibus/config/software/python3.rb +++ b/omnibus/config/software/python3.rb @@ -2,6 +2,10 @@ default_version "3.13.15" +# Omnibus does not fingerprint Bazel source patches. Always invoke Bazel so its +# own cache validates the CPython inputs, including same-version security fixes. +always_build true + # [sts] STAC-24773 Phase D1: Python via Bazel @cpython (replaces omnibus source build). # Mirrors origin/base-7.78.2 with --downloader_config=/dev/null on every bazelisk # invocation (STS runner egress workaround; see datadog-agent-dependencies.rb). From a14cde7c227988cd31cfee15f71fbaed8c56605e Mon Sep 17 00:00:00 2001 From: Louis Lotter Date: Fri, 18 Sep 2026 13:51:08 +0200 Subject: [PATCH 5/6] Limit Python security backports to reachable StringPrep defect --- ...-2026-15806-urllib-credential-scheme.patch | 195 ------------------ .../0004-CVE-2026-19672-tarfile-path.patch | 98 --------- .../0005-CVE-2025-15367-poplib-commands.patch | 62 ------ .../0007-CVE-2026-15310-zipfile-bounds.patch | 178 ---------------- .../0008-CVE-2026-15310-python313-tests.patch | 27 --- ...026-15310-decompressor-compatibility.patch | 161 --------------- .../0010-CVE-2026-87910-tarfile-filter.patch | 93 --------- .../0011-CVE-2026-87910-windows-test.patch | 46 ----- deps/cpython/SECURITY_PATCHES.md | 58 ++---- deps/cpython/cpython.MODULE.bazel | 8 - ...python-security-backports-35310940870.yaml | 10 +- scripts/test_embedded_python_security.py | 114 +--------- 12 files changed, 24 insertions(+), 1026 deletions(-) delete mode 100644 deps/cpython/0003-CVE-2026-15806-urllib-credential-scheme.patch delete mode 100644 deps/cpython/0004-CVE-2026-19672-tarfile-path.patch delete mode 100644 deps/cpython/0005-CVE-2025-15367-poplib-commands.patch delete mode 100644 deps/cpython/0007-CVE-2026-15310-zipfile-bounds.patch delete mode 100644 deps/cpython/0008-CVE-2026-15310-python313-tests.patch delete mode 100644 deps/cpython/0009-CVE-2026-15310-decompressor-compatibility.patch delete mode 100644 deps/cpython/0010-CVE-2026-87910-tarfile-filter.patch delete mode 100644 deps/cpython/0011-CVE-2026-87910-windows-test.patch diff --git a/deps/cpython/0003-CVE-2026-15806-urllib-credential-scheme.patch b/deps/cpython/0003-CVE-2026-15806-urllib-credential-scheme.patch deleted file mode 100644 index 1f3d7daf6807..000000000000 --- a/deps/cpython/0003-CVE-2026-15806-urllib-credential-scheme.patch +++ /dev/null @@ -1,195 +0,0 @@ -From a2773a34183b7d94a243bb98fd658926cc5348ce Mon Sep 17 00:00:00 2001 -From: "Miss Islington (bot)" - <31488909+miss-islington@users.noreply.github.com> -Date: Tue, 18 Aug 2026 09:25:56 +0200 -Subject: [PATCH] [3.13] gh-155694: Scope HTTPPasswordMgr credentials by URL - scheme (GH-155696) (#155970) -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit - -gh-155694: Scope HTTPPasswordMgr credentials by URL scheme (GH-155696) - -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 ---- - Doc/library/urllib.request.rst | 10 +++- - Lib/test/test_urllib2.py | 56 +++++++++++++++++++ - Lib/urllib/request.py | 25 +++++++-- - ...-07-31-16-20-17.gh-issue-155694.SsxlKG.rst | 4 ++ - 4 files changed, 87 insertions(+), 8 deletions(-) - create mode 100644 Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst - -diff --git a/Doc/library/urllib.request.rst b/Doc/library/urllib.request.rst -index 4107017021f5abf..3d6199b592ca62b 100644 ---- a/Doc/library/urllib.request.rst -+++ b/Doc/library/urllib.request.rst -@@ -943,8 +943,14 @@ These methods are available on :class:`HTTPPasswordMgr` and - - *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) -diff --git a/Lib/test/test_urllib2.py b/Lib/test/test_urllib2.py -index d94021b31b19ff3..229da626bb78300 100644 ---- a/Lib/test/test_urllib2.py -+++ b/Lib/test/test_urllib2.py -@@ -273,6 +273,50 @@ def test_password_manager_default_port(self): - 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 = [] -@@ -1795,6 +1839,18 @@ def test_basic_prior_auth_auto_send(self): - # 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 - -diff --git a/Lib/urllib/request.py b/Lib/urllib/request.py -index 42147f31d968c77..37af2e3a580da5d 100644 ---- a/Lib/urllib/request.py -+++ b/Lib/urllib/request.py -@@ -815,16 +815,17 @@ def add_password(self, realm, uri, user, passwd): - 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 - -@@ -851,6 +852,17 @@ def reduce_uri(self, uri, default_port=True): - 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 - -@@ -896,14 +908,15 @@ def update_authenticated(self, uri, is_authenticated=False): - - 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] - - -diff --git a/Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst b/Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst -new file mode 100644 -index 000000000000000..dbc2119640702c9 ---- /dev/null -+++ b/Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst -@@ -0,0 +1,4 @@ -+Fix :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. diff --git a/deps/cpython/0004-CVE-2026-19672-tarfile-path.patch b/deps/cpython/0004-CVE-2026-19672-tarfile-path.patch deleted file mode 100644 index 07250a72123a..000000000000 --- a/deps/cpython/0004-CVE-2026-19672-tarfile-path.patch +++ /dev/null @@ -1,98 +0,0 @@ -From c7979f3a819011a3222bd16e671264b1e34282cb Mon Sep 17 00:00:00 2001 -From: "Miss Islington (bot)" - <31488909+miss-islington@users.noreply.github.com> -Date: Wed, 19 Aug 2026 15:33:25 +0200 -Subject: [PATCH] [3.13] gh-155999: `tarfile`: handle a member that leaves the - destination but comes back (GH-156000) (#156042) - -(cherry picked from commit 97688346ada2df3e5b9c279348862c3d64ab0823) - -Co-authored-by: Stan Ulbrych ---- - Doc/library/tarfile.rst | 8 ++++++++ - Lib/tarfile.py | 7 +++++++ - Lib/test/test_tarfile.py | 14 ++++++++++++++ - .../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 - -diff --git a/Doc/library/tarfile.rst b/Doc/library/tarfile.rst -index c820e3d159b9a5b..d5c5328210cd7e4 100644 ---- a/Doc/library/tarfile.rst -+++ b/Doc/library/tarfile.rst -@@ -1049,6 +1049,10 @@ reused in custom filters: - 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 ` to extract files whose absolute - path (after following symlinks) would end up outside the destination. - This raises :class:`~tarfile.OutsideDestinationError`. -@@ -1057,6 +1061,10 @@ reused in custom filters: - - Return the modified ``TarInfo`` member. - -+ .. versionchanged:: next -+ -+ Filenames containing ``..`` components are now normalized. -+ - .. function:: data_filter(member, path) - - Implements the ``'data'`` filter. -diff --git a/Lib/tarfile.py b/Lib/tarfile.py -index 9da2667abe15da6..3c59eac5d9a0f11 100755 ---- a/Lib/tarfile.py -+++ b/Lib/tarfile.py -@@ -808,6 +808,13 @@ def _get_filtered_attrs(member, dest_path, for_data=True): - # 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: -diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py -index 31e844328b31901..86fe8efac2e3495 100644 ---- a/Lib/test/test_tarfile.py -+++ b/Lib/test/test_tarfile.py -@@ -3948,6 +3948,20 @@ def test_absolute(self): - tarfile.AbsolutePathError, - """['"].*escaped.evil['"] has an absolute path""") - -+ def test_parent_dir_out_and_back(self): -+ # Test a member that leaves the destination and comes back. -+ # The containment check looks at the resolved path, which stays -+ # inside, but the intermediate directories are created from the -+ # name as given, which does not. -+ with ArchiveMaker() as arc: -+ arc.add(f'../escaped.evil/../{self.destdir.name}/sub/file', -+ content='content') -+ -+ for filter in 'tar', 'data': -+ with self.subTest(filter): -+ with self.check_context(arc.open(), filter): -+ self.expect_file('sub/file', content='content') -+ - @symlink_test - def test_parent_symlink(self): - # Test interplaying symlinks -diff --git a/Misc/NEWS.d/next/Security/2026-08-13-13-08-11.gh-issue-155999.Xt4rWq.rst b/Misc/NEWS.d/next/Security/2026-08-13-13-08-11.gh-issue-155999.Xt4rWq.rst -new file mode 100644 -index 000000000000000..59b725e55bbffda ---- /dev/null -+++ b/Misc/NEWS.d/next/Security/2026-08-13-13-08-11.gh-issue-155999.Xt4rWq.rst -@@ -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. diff --git a/deps/cpython/0005-CVE-2025-15367-poplib-commands.patch b/deps/cpython/0005-CVE-2025-15367-poplib-commands.patch deleted file mode 100644 index 88576bfff4a1..000000000000 --- a/deps/cpython/0005-CVE-2025-15367-poplib-commands.patch +++ /dev/null @@ -1,62 +0,0 @@ -From b234a2b67539f787e191d2ef19a7cbdce32874e7 Mon Sep 17 00:00:00 2001 -From: Seth Michael Larson -Date: Tue, 20 Jan 2026 14:46:32 -0600 -Subject: [PATCH] gh-143923: Reject control characters in POP3 commands - -Backport to 3.13.15: adjust test import context for ExtraAssertions; -production fix and upstream regression are unchanged. - ---- - Lib/poplib.py | 2 ++ - Lib/test/test_poplib.py | 8 ++++++++ - .../2026-01-16-11-43-47.gh-issue-143923.DuytMe.rst | 1 + - 3 files changed, 11 insertions(+) - create mode 100644 Misc/NEWS.d/next/Security/2026-01-16-11-43-47.gh-issue-143923.DuytMe.rst - -diff --git a/Lib/poplib.py b/Lib/poplib.py -index 4469bff44b4c455..b97274c5c32ee63 100644 ---- a/Lib/poplib.py -+++ b/Lib/poplib.py -@@ -122,6 +122,8 @@ def _putline(self, line): - def _putcmd(self, line): - if self._debugging: print('*cmd*', repr(line)) - line = bytes(line, self.encoding) -+ if re.search(b'[\x00-\x1F\x7F]', line): -+ raise ValueError('Control characters not allowed in commands') - self._putline(line) - - -diff --git a/Lib/test/test_poplib.py b/Lib/test/test_poplib.py -index ef2da97f86734a2..18ca7cb556836e6 100644 ---- a/Lib/test/test_poplib.py -+++ b/Lib/test/test_poplib.py -@@ -17,7 +17,8 @@ - from test.support import threading_helper - from test.support import asynchat - from test.support import asyncore -+from test.support import control_characters_c0 - from test.support.testcase import ExtraAssertions - - - test_support.requires_working_socket(module=True) -@@ -395,6 +396,13 @@ def test_quit(self): - self.assertIsNone(self.client.sock) - self.assertIsNone(self.client.file) - -+ def test_control_characters(self): -+ for c0 in control_characters_c0(): -+ with self.assertRaises(ValueError): -+ self.client.user(f'user{c0}') -+ with self.assertRaises(ValueError): -+ self.client.pass_(f'{c0}pass') -+ - @requires_ssl - def test_stls_capa(self): - capa = self.client.capa() -diff --git a/Misc/NEWS.d/next/Security/2026-01-16-11-43-47.gh-issue-143923.DuytMe.rst b/Misc/NEWS.d/next/Security/2026-01-16-11-43-47.gh-issue-143923.DuytMe.rst -new file mode 100644 -index 000000000000000..3cde4df3e0069f7 ---- /dev/null -+++ b/Misc/NEWS.d/next/Security/2026-01-16-11-43-47.gh-issue-143923.DuytMe.rst -@@ -0,0 +1 @@ -+Reject control characters in POP3 commands. diff --git a/deps/cpython/0007-CVE-2026-15310-zipfile-bounds.patch b/deps/cpython/0007-CVE-2026-15310-zipfile-bounds.patch deleted file mode 100644 index 88f84b2801d0..000000000000 --- a/deps/cpython/0007-CVE-2026-15310-zipfile-bounds.patch +++ /dev/null @@ -1,178 +0,0 @@ -From 7a2b7388233ea0c647168c5792dc31f3051fe177 Mon Sep 17 00:00:00 2001 -From: Petr Viktorin -Date: Mon, 31 Aug 2026 21:04:04 +0200 -Subject: [PATCH 1/3] [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 -Co-authored-by: tonghuaroot ---- - Lib/test/test_zipfile/test_core.py | 42 +++++++++++++++++++ - Lib/zipfile/__init__.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/test_core.py b/Lib/test/test_zipfile/test_core.py -index 57bd71481a3762..b04ba512b8e34e 100644 ---- a/Lib/test/test_zipfile/test_core.py -+++ b/Lib/test/test_zipfile/test_core.py -@@ -2619,6 +2619,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/__init__.py b/Lib/zipfile/__init__.py -index 37555d32d99730..8753c07f61e35e 100644 ---- a/Lib/zipfile/__init__.py -+++ b/Lib/zipfile/__init__.py -@@ -726,7 +726,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: -@@ -742,7 +751,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 - -@@ -802,6 +811,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: -@@ -1102,8 +1118,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 -@@ -1116,8 +1139,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 00000000000000..4e49ad5ce8fa00 ---- /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. - diff --git a/deps/cpython/0008-CVE-2026-15310-python313-tests.patch b/deps/cpython/0008-CVE-2026-15310-python313-tests.patch deleted file mode 100644 index 461315a678f8..000000000000 --- a/deps/cpython/0008-CVE-2026-15310-python313-tests.patch +++ /dev/null @@ -1,27 +0,0 @@ -From d53a41e8cf0148af073c46abc36c2969adcb70c2 Mon Sep 17 00:00:00 2001 -From: Petr Viktorin -Date: Thu, 3 Sep 2026 16:39:39 +0200 -Subject: [PATCH 2/3] Remove zstd test (3.14+) - ---- - Lib/test/test_zipfile/test_core.py | 6 ------ - 1 file changed, 6 deletions(-) - -diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py -index b04ba512b8e34e..4e4a74b2df42ec 100644 ---- a/Lib/test/test_zipfile/test_core.py -+++ b/Lib/test/test_zipfile/test_core.py -@@ -2655,12 +2655,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.""" - diff --git a/deps/cpython/0009-CVE-2026-15310-decompressor-compatibility.patch b/deps/cpython/0009-CVE-2026-15310-decompressor-compatibility.patch deleted file mode 100644 index 45f449d07552..000000000000 --- a/deps/cpython/0009-CVE-2026-15310-decompressor-compatibility.patch +++ /dev/null @@ -1,161 +0,0 @@ -From f3f5234facd2ba491b3bff9e0ac9bd54378314ce Mon Sep 17 00:00:00 2001 -From: "Miss Islington (bot)" - <31488909+miss-islington@users.noreply.github.com> -Date: Wed, 16 Sep 2026 06:52:44 -0700 -Subject: [PATCH 3/3] gh-156002: Keep reading through monkey-patched zipfile - decompressors (GH-157180) (GH-157557) - -(cherry picked from commit f507e6946a3194e83e1d7b8ee6e14567175e46de) - -Co-authored-by: Petr Viktorin -Co-authored-by: rasmusfaber ---- - Lib/test/test_zipfile/test_core.py | 68 +++++++++++++++++++ - Lib/zipfile/__init__.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/test_core.py b/Lib/test/test_zipfile/test_core.py -index 4e4a74b2df42ec..05e39faa104555 100644 ---- a/Lib/test/test_zipfile/test_core.py -+++ b/Lib/test/test_zipfile/test_core.py -@@ -2655,6 +2655,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/__init__.py b/Lib/zipfile/__init__.py -index 8753c07f61e35e..a64f831ecddac3 100644 ---- a/Lib/zipfile/__init__.py -+++ b/Lib/zipfile/__init__.py -@@ -727,7 +727,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. -@@ -811,13 +811,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: -@@ -1123,7 +1116,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'' -@@ -1142,10 +1135,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 00000000000000..a21386803cca0f ---- /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`. diff --git a/deps/cpython/0010-CVE-2026-87910-tarfile-filter.patch b/deps/cpython/0010-CVE-2026-87910-tarfile-filter.patch deleted file mode 100644 index 9e16635435ef..000000000000 --- a/deps/cpython/0010-CVE-2026-87910-tarfile-filter.patch +++ /dev/null @@ -1,93 +0,0 @@ -From 05f187c81a6e2d90d627d96f2ad0b6724f044aef Mon Sep 17 00:00:00 2001 -From: Petr Viktorin -Date: Fri, 11 Sep 2026 14:19:35 +0200 -Subject: [PATCH 1/2] gh-157265: tarfile: Honor None result of filter for link - fallbacks (GH-157266) - -(cherry picked from commit fb2f0bbc3b35264f09cc2cb2934b7987527a6bc2) - -Co-authored-by: Petr Viktorin -Co-authored-by: Stan Ulbrych ---- - Lib/tarfile.py | 4 ++- - Lib/test/test_tarfile.py | 31 +++++++++++++++++-- - ...-09-10-13-38-11.gh-issue-157265.-vYuMp.rst | 3 ++ - 3 files changed, 34 insertions(+), 4 deletions(-) - create mode 100644 Misc/NEWS.d/next/Security/2026-09-10-13-38-11.gh-issue-157265.-vYuMp.rst - -diff --git a/Lib/tarfile.py b/Lib/tarfile.py -index e68d7bbf3afb79c..5bc1aac8bec2afb 100755 ---- a/Lib/tarfile.py -+++ b/Lib/tarfile.py -@@ -2764,9 +2764,11 @@ def makelink_with_filter(self, tarinfo, targetpath, - "makelink_with_filter: if filter_function is not None, " - + "extraction_root must also not be None") - try: -- filter_function( -+ filtered = filter_function( - unfiltered.replace(name=tarinfo.name, deep=False), - extraction_root) -+ if filtered is None: -+ return - filtered = filter_function(unfiltered, extraction_root) - except _FILTER_ERRORS as cause: - raise LinkFallbackError(tarinfo, unfiltered.name) from cause -diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py -index 34e9fb587725cb3..4fc06621469eed8 100644 ---- a/Lib/test/test_tarfile.py -+++ b/Lib/test/test_tarfile.py -@@ -4418,9 +4418,15 @@ def test_sneaky_hardlink_fallback(self): - 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/t/dummy") -- self.expect_file("b/") -- self.expect_file("c/") -+ if filter == 'tar': -+ self.expect_exception( -+ tarfile.LinkFallbackError, -+ "link 'boom' would be extracted as a copy of " -+ + "'c/escape', which was rejected") -+ else: -+ self.expect_file("a/t/dummy") -+ self.expect_file("b/") -+ self.expect_file("c/") - else: - self.expect_file("a/t/dummy") - self.expect_file("b/") -@@ -4617,6 +4623,25 @@ def testing_filter(member, path): - if os_helper.can_chmod(): - self.assertFalse(path.stat().st_mode & stat.S_IWUSR) - -+ @symlink_test -+ def test_extract_filters_target_none(self): -+ # Test that when extract() falls back to extracting (rather than -+ # linking) a hardlink target, the member is skipped if the filter -+ # returns None. -+ with ArchiveMaker() as arc: -+ arc.add('a/b/s', symlink_to='../escape') -+ arc.add('q', hardlink_to='a/b/s') -+ def filter_unsafe_members(member, path): -+ try: -+ return tarfile.data_filter(member, path) -+ except tarfile.FilterError as error: -+ return None -+ with self.check_context(arc.open(), filter_unsafe_members): -+ if os_helper.can_symlink(): -+ self.expect_file('a/b/s', symlink_to='../escape') -+ else: -+ self.expect_file('a/b/') # symlink is not extracted -+ - def test_link_fallback_normalizes(self): - # Make sure hardlink fallbacks work for non-normalized paths for all - # filters -diff --git a/Misc/NEWS.d/next/Security/2026-09-10-13-38-11.gh-issue-157265.-vYuMp.rst b/Misc/NEWS.d/next/Security/2026-09-10-13-38-11.gh-issue-157265.-vYuMp.rst -new file mode 100644 -index 000000000000000..ba27e47f734bfb1 ---- /dev/null -+++ b/Misc/NEWS.d/next/Security/2026-09-10-13-38-11.gh-issue-157265.-vYuMp.rst -@@ -0,0 +1,3 @@ -+In :mod:`tarfile`, when extracting a link falls back to extracting a member -+of the archive, skip the member when the filter function returns None when -+called with the extracted member's name replaced with the link's. - diff --git a/deps/cpython/0011-CVE-2026-87910-windows-test.patch b/deps/cpython/0011-CVE-2026-87910-windows-test.patch deleted file mode 100644 index 06f7b52060b4..000000000000 --- a/deps/cpython/0011-CVE-2026-87910-windows-test.patch +++ /dev/null @@ -1,46 +0,0 @@ -From f93473e550cc2e75f3ebc5cc890ca1af318efe36 Mon Sep 17 00:00:00 2001 -From: Petr Viktorin -Date: Fri, 11 Sep 2026 19:54:41 +0200 -Subject: [PATCH 2/2] gh-157265: Adjust test for Windows (GH-157334) - -gh-157266: Adjust test for Windows - -On Windows (no symlinks, no hardlinks), the behaviour is -the same as without the fix in GH-157266: -- a/t/dummy is extracted -- b/ is extracted -- c/ is *not* created (the target, a/t, is not in the archive) -- c/escape: c/ is created; escape is skipped (target, - c/../../link_here, is not in archive) -- c is not recreated as a directory -- boom is not created (target is c/escape, which falls back to - ..\..\link_here, which does not exist in archive) ---- - Lib/test/test_tarfile.py | 10 +++++----- - 1 file changed, 5 insertions(+), 5 deletions(-) - -diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py -index 4fc06621469eed8..726ecff80124f8e 100644 ---- a/Lib/test/test_tarfile.py -+++ b/Lib/test/test_tarfile.py -@@ -4418,15 +4418,15 @@ def test_sneaky_hardlink_fallback(self): - for filter in 'tar', 'fully_trusted': - with self.subTest(filter), self.check_context(arc.open(), filter): - if not os_helper.can_symlink(): -- if filter == 'tar': -+ if filter == 'fully_trusted' or sys.platform == "win32": -+ self.expect_file("a/t/dummy") -+ self.expect_file("b/") -+ self.expect_file("c/") -+ else: - self.expect_exception( - tarfile.LinkFallbackError, - "link 'boom' would be extracted as a copy of " - + "'c/escape', which was rejected") -- else: -- self.expect_file("a/t/dummy") -- self.expect_file("b/") -- self.expect_file("c/") - else: - self.expect_file("a/t/dummy") - self.expect_file("b/") diff --git a/deps/cpython/SECURITY_PATCHES.md b/deps/cpython/SECURITY_PATCHES.md index 4f6e2d4f31da..85dbaba5446a 100644 --- a/deps/cpython/SECURITY_PATCHES.md +++ b/deps/cpython/SECURITY_PATCHES.md @@ -1,44 +1,20 @@ -# Embedded CPython security patches +# Embedded CPython security patch -The agent packages CPython 3.13.15. `cpython.MODULE.bazel` applies the following -upstream fixes during the source build on every supported platform. The version -string stays 3.13.15; version-only vulnerability scanners can still report these -CVEs. These patches do not change VEX or exception decisions. +CVE-2026-17084 is fixed by the unchanged upstream Python 3.13 backport +[c28b121a](https://github.com/python/cpython/commit/c28b121a4f0b975937c8b5a1b4934bb361d84296). +It corrects StringPrep's Unicode tables used by the shipped IDNA call paths. +Python continues to report 3.13.15; image-specific `fixed` VEX must identify +artifacts containing this patch, never all Python 3.13.15 installations. -The Omnibus `python3` recipe uses `always_build true`: Omnibus does not fingerprint -Bazel patch inputs, so Bazel must validate its own cache even when the Python -version stays unchanged. +The patch includes upstream regression tests. Existing package CI also checks +IDNA behavior using each architecture's packaged interpreter. Remove the patch +when upgrading to a release containing the fix and keep the runtime regression. +Omnibus must invoke Bazel even at the same Python version so restored caches +cannot bypass changed patch inputs. -| CVE | Upstream source | Local adaptation | -| --- | --- | --- | -| CVE-2026-15806 | [3.13 commit a2773a34](https://github.com/python/cpython/commit/a2773a34183b7d94a243bb98fd658926cc5348ce) | None | -| CVE-2026-19672 | [3.13 commit c7979f3a](https://github.com/python/cpython/commit/c7979f3a819011a3222bd16e671264b1e34282cb) | None; test hunk has a line offset | -| CVE-2026-15310 | [3.13 PR156738](https://github.com/python/cpython/pull/156738), commits `7a2b7388233ea0c647168c5792dc31f3051fe177`, `d53a41e8cf0148af073c46abc36c2969adcb70c2`, `f3f5234facd2ba491b3bff9e0ac9bd54378314ce` | None; includes removal of unsupported Zstandard tests and the third-party decompressor compatibility correction | -| CVE-2026-87910 | [3.13 PR157308](https://github.com/python/cpython/pull/157308), commits `05f187c81a6e2d90d627d96f2ad0b6724f044aef`, `f93473e550cc2e75f3ebc5cc890ca1af318efe36` | None; includes the Windows regression-test correction | -| CVE-2026-17084 | [3.13 commit c28b121a](https://github.com/python/cpython/commit/c28b121a4f0b975937c8b5a1b4934bb361d84296) | None; preserves the 3.13-specific generated Unicode tables | -| CVE-2025-15367 | [commit b234a2b6](https://github.com/python/cpython/commit/b234a2b67539f787e191d2ef19a7cbdce32874e7) | Test import context accounts for 3.13's existing `ExtraAssertions` import; production fix and test logic unchanged | - -Each patch retains upstream regression tests. With a patched CPython source build, -run `./python -m test test_urllib2 test_tarfile test_poplib test_codecs test_unicodedata test_stringprep test_zipfile`. The agent image CI -also runs `scripts/test_embedded_python_security.py` with the actual packaged -interpreter on AMD64 and ARM64. This checks the six security boundaries and -valid operations without external network access. Image vulnerability and secret -scans remain separate steps. Remove a backport only when an upstream release -includes it and the packaged-interpreter tests pass. - -Upstream status checked 2026-09-18: - -The 3.13 zipfile and tar-link backport PRs remain open upstream. Their main-branch -fixes are merged. The copied zipfile series includes the compatibility correction -from [GH-157180](https://github.com/python/cpython/pull/157180); the tar-link series -includes the Windows test correction from -[GH-157334](https://github.com/python/cpython/pull/157334). Their open status is -retained for independent review; they are not represented as released Python -3.13 fixes. The other 3.13 backports are merged; POP3 is adapted from its merged -main-branch fix. Runtime regression tests are mandatory for all six fixes. - -The latest upstream 3.13 tag checked was 3.13.15. The 3.15.0rc2 scanner leads are -not used as a runtime upgrade. Version-only findings for these six CVEs may remain -until upstream release/scanner metadata or a separately approved applicability -decision accounts for the backports. No applicability decision, suppression, or -exception renewal is part of this work. PR511 and GO-2026-5932 remain outside scope. +The other five backports from the original candidate remain in git history. +Four findings already have reviewed image-scoped VEX in +[StackVista/vexhub](https://github.com/StackVista/vexhub/blob/main/pkg/oci/stackstate-k8s-agent/scan.openvex.json). +Tar-link CVE-2026-87910 is handled by a separate image-scoped applicability +assessment; it is not declared fixed by this patch. Existing VEX and exception +decisions outside this change remain intact. diff --git a/deps/cpython/cpython.MODULE.bazel b/deps/cpython/cpython.MODULE.bazel index 6fc3c4d3d436..9aeb5fb69af0 100644 --- a/deps/cpython/cpython.MODULE.bazel +++ b/deps/cpython/cpython.MODULE.bazel @@ -12,15 +12,7 @@ http_archive( patches = [ "//deps/cpython:0001-customize-windows-build-script.patch", "//deps/cpython:0002-Set-the-install-name-to-use-rpath-instead-of-absolut.patch", - "//deps/cpython:0003-CVE-2026-15806-urllib-credential-scheme.patch", - "//deps/cpython:0004-CVE-2026-19672-tarfile-path.patch", - "//deps/cpython:0005-CVE-2025-15367-poplib-commands.patch", "//deps/cpython:0006-CVE-2026-17084-stringprep-unicode.patch", - "//deps/cpython:0007-CVE-2026-15310-zipfile-bounds.patch", - "//deps/cpython:0008-CVE-2026-15310-python313-tests.patch", - "//deps/cpython:0009-CVE-2026-15310-decompressor-compatibility.patch", - "//deps/cpython:0010-CVE-2026-87910-tarfile-filter.patch", - "//deps/cpython:0011-CVE-2026-87910-windows-test.patch", ], sha256 = "c28d9d213c09b5b5ab2c29812950e12f746999e099b82894231be954b26baed9", strip_prefix = "Python-{}".format(PYTHON_VERSION), diff --git a/releasenotes/notes/python-security-backports-35310940870.yaml b/releasenotes/notes/python-security-backports-35310940870.yaml index 5e204ce85163..99b04e6d0f7f 100644 --- a/releasenotes/notes/python-security-backports-35310940870.yaml +++ b/releasenotes/notes/python-security-backports-35310940870.yaml @@ -1,11 +1,5 @@ --- security: - | - Backport Python fixes for CVE-2026-15806, CVE-2026-19672, CVE-2025-15367, - CVE-2026-17084, CVE-2026-15310 and CVE-2026-87910. - HTTP authentication credentials are scoped by URL scheme, tar extraction - filters avoid creating directories outside the destination, and POP3 - commands reject control characters. IDNA string preparation uses the - Unicode 3.2 case-folding rules required by RFC 3454. The Python version remains - 3.13.15 with these source patches applied. ZIP reads bound decompression - memory, and tar link fallbacks honor extraction-filter rejections. + Backport CVE-2026-17084 to embedded Python 3.13.15 so IDNA string + preparation uses the Unicode 3.2 case-folding rules required by RFC 3454. diff --git a/scripts/test_embedded_python_security.py b/scripts/test_embedded_python_security.py index d8d92c729526..26fb8983fac2 100644 --- a/scripts/test_embedded_python_security.py +++ b/scripts/test_embedded_python_security.py @@ -1,103 +1,13 @@ -"""Regressions to run with the Python interpreter shipped in the agent image.""" +"""Check the StringPrep backport using the packaged Python interpreter.""" import hashlib -import io -import poplib import stringprep import sys -import tarfile -import tempfile import unittest -import urllib.request -import zipfile from pathlib import Path -from unittest.mock import Mock, patch class EmbeddedPythonSecurityTests(unittest.TestCase): - def test_credentials_are_scoped_by_scheme(self): - for manager in ( - urllib.request.HTTPPasswordMgr, - urllib.request.HTTPPasswordMgrWithDefaultRealm, - urllib.request.HTTPPasswordMgrWithPriorAuth, - ): - with self.subTest(manager=manager.__name__): - passwords = manager() - passwords.add_password(None, "https://example.invalid/", "user", "test-value") - self.assertEqual( - passwords.find_user_password(None, "https://example.invalid/"), - ("user", "test-value"), - ) - self.assertEqual(passwords.find_user_password(None, "http://example.invalid/"), (None, None)) - passwords.add_password(None, "proxy.invalid", "proxy", "test-value") - for scheme in ("http", "https"): - self.assertEqual( - passwords.find_user_password(None, f"{scheme}://proxy.invalid/"), - ("proxy", "test-value"), - ) - - def test_tar_filters_do_not_create_directories_outside_destination(self): - for extraction_filter in ("tar", "data"): - with self.subTest(filter=extraction_filter), tempfile.TemporaryDirectory() as root: - destination = Path(root) / "destination" - destination.mkdir() - archive = io.BytesIO() - content = b"expected content" - with tarfile.open(fileobj=archive, mode="w") as writer: - member = tarfile.TarInfo("../outside/../destination/sub/file") - member.size = len(content) - writer.addfile(member, io.BytesIO(content)) - archive.seek(0) - with tarfile.open(fileobj=archive) as reader: - reader.extractall(destination, filter=extraction_filter) - self.assertEqual((destination / "sub/file").read_bytes(), content) - self.assertFalse((Path(root) / "outside").exists()) - - def test_zipfile_small_reads_bound_decompression(self): - content = b"\0" * (4 * 1024 * 1024) - for compression in (zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA): - with self.subTest(compression=compression): - archive = io.BytesIO() - with zipfile.ZipFile(archive, "w", compression=compression) as writer: - writer.writestr("content", content) - archive.seek(0) - with zipfile.ZipFile(archive) as reader, reader.open("content") as member: - first = member._read1(100) - self.assertLessEqual(len(first), member.MIN_READ_SIZE) - self.assertEqual(first + member.read(), content) - - def test_tar_link_fallback_honors_filter_rejection(self): - with tempfile.TemporaryDirectory() as destination: - archive = io.BytesIO() - with tarfile.open(fileobj=archive, mode="w") as writer: - symlink = tarfile.TarInfo("a/b/s") - symlink.type = tarfile.SYMTYPE - symlink.linkname = "../escape" - writer.addfile(symlink) - hardlink = tarfile.TarInfo("q") - hardlink.type = tarfile.LNKTYPE - hardlink.linkname = "a/b/s" - writer.addfile(hardlink) - rejected = [] - - def skip_unsafe(member, path): - try: - return tarfile.data_filter(member, path) - except tarfile.FilterError: - rejected.append(member.name) - return None - - archive.seek(0) - with ( - tarfile.open(fileobj=archive) as reader, - patch("tarfile.os.link", side_effect=OSError("Exercise link fallback")), - ): - reader.extractall(destination, filter=skip_unsafe) - self.assertIn("q", rejected) - self.assertTrue((Path(destination) / "a/b/s").is_symlink()) - self.assertFalse((Path(destination) / "q").is_symlink()) - self.assertFalse((Path(destination) / "q").exists()) - def test_idna_uses_unicode_3_2_case_folding(self): cases = ( ("\N{CHEROKEE LETTER A}\N{CHEROKEE LETTER A}", b"xn--58da"), @@ -110,23 +20,9 @@ def test_idna_uses_unicode_3_2_case_folding(self): self.assertEqual(name.encode("idna"), encoded) self.assertEqual("example.invalid".encode("idna"), b"example.invalid") - def test_pop3_rejects_control_characters_before_sending(self): - client = poplib.POP3.__new__(poplib.POP3) - client._debugging = 0 - client.encoding = "utf-8" - client._putline = Mock() - client._putcmd("USER valid-user") - client._putline.assert_called_once_with(b"USER valid-user") - client._putline.reset_mock() - for character in (*range(32), 127): - with self.subTest(character=character), self.assertRaises(ValueError): - client._putcmd(f"USER invalid{chr(character)}value") - client._putline.assert_not_called() - if __name__ == "__main__": - print(f"Embedded interpreter: {sys.executable}; version: {sys.version}", flush=True) - for module in (urllib.request, tarfile, poplib, stringprep, zipfile): - source = Path(module.__file__) - print(f"{module.__name__}: {source}; sha256={hashlib.sha256(source.read_bytes()).hexdigest()}", flush=True) - unittest.main(verbosity=2) + print(sys.version) + module = Path(stringprep.__file__) + print(f"{module}: {hashlib.sha256(module.read_bytes()).hexdigest()}") + unittest.main() From 9f51215785bbf3c400065aeb3e3eb8c94e7f18ec Mon Sep 17 00:00:00 2001 From: Louis Lotter Date: Fri, 18 Sep 2026 13:58:41 +0200 Subject: [PATCH 6/6] Preserve image-scoped VEX matching for local agent scans Grype 0.112.0 derives a tag-only image's OCI PURL from its source name. On unpublished Docker images the inferred name includes the registry path, so our existing stackstate-k8s-agent VEX subjects do not match. Supplying the product name restores the four existing matches without changing the VEX scope; the same archive still reports the two unassessed Python findings. --- .github/workflows/build-deb.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-deb.yml b/.github/workflows/build-deb.yml index 062ca03a630b..b3ffcdf65bb3 100644 --- a/.github/workflows/build-deb.yml +++ b/.github/workflows/build-deb.yml @@ -327,6 +327,8 @@ jobs: - name: Scan agent image, report-only (Trivy and Grype vulnerabilities, VEX-aware, plus Trivy secrets) uses: StackVista/image-pipeline/.github/actions/scan-image@ab8ac3d608530ee0a295483c973d720230174348 + env: + GRYPE_NAME: stackstate-k8s-agent with: image: ${{ env.LOCAL_IMAGE }} mode: inform