Version in base suite: 3.13.5-2+deb13u2 Base version: python3.13_3.13.5-2+deb13u2 Target version: python3.13_3.13.5-2+deb13u3 Base file: /srv/ftp-master.debian.org/ftp/pool/main/p/python3.13/python3.13_3.13.5-2+deb13u2.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/p/python3.13/python3.13_3.13.5-2+deb13u3.dsc changelog | 19 ++ patches/CVE-2026-1502.patch | 87 +++++++++++++ patches/CVE-2026-3276.patch | 231 +++++++++++++++++++++++++++++++++++ patches/CVE-2026-6019-2.patch | 124 ++++++++++++++++++ patches/CVE-2026-7774.patch | 225 ++++++++++++++++++++++++++++++++++ patches/CVE-2026-8328.patch | 79 +++++++++++ patches/CVE-2026-9669.patch | 82 ++++++++++++ patches/series | 9 + patches/ssl-reference-leak.patch | 198 ++++++++++++++++++++++++++++++ patches/ssl-sni-crash.patch | 184 +++++++++++++++++++++++++++ patches/traverse-managed-dicts.patch | 90 +++++++++++++ 11 files changed, 1328 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp8x2891r4/python3.13_3.13.5-2+deb13u2.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp8x2891r4/python3.13_3.13.5-2+deb13u3.dsc: no acceptable signature found diff -Nru python3.13-3.13.5/debian/changelog python3.13-3.13.5/debian/changelog --- python3.13-3.13.5/debian/changelog 2026-05-05 21:05:52.000000000 +0000 +++ python3.13-3.13.5/debian/changelog 2026-06-13 14:18:01.000000000 +0000 @@ -1,3 +1,22 @@ +python3.13 (3.13.5-2+deb13u3) trixie; urgency=medium + + [ Stefano Rivera ] + * Patches: + - Fix a crash in SNI callback when the SSL object is gone. + - Fix reference leaks in ssl.SSLContext objects. (Closes: #1138157) + - Avoid garbage collecting objects too early when sharing __dict__ + (Closes: #1108039) + - Update the patch for CVE-2026-6019 to use decodeURIComponent. + + [ Moritz Mühlenhoff ] + * CVE-2026-1502 + * CVE-2026-3276 + * CVE-2026-7774 + * CVE-2026-8328 + * CVE-2026-9669 + + -- Stefano Rivera Sat, 13 Jun 2026 10:18:01 -0400 + python3.13 (3.13.5-2+deb13u2) trixie; urgency=medium * CVE-2026-3446 diff -Nru python3.13-3.13.5/debian/patches/CVE-2026-1502.patch python3.13-3.13.5/debian/patches/CVE-2026-1502.patch --- python3.13-3.13.5/debian/patches/CVE-2026-1502.patch 1970-01-01 00:00:00.000000000 +0000 +++ python3.13-3.13.5/debian/patches/CVE-2026-1502.patch 2026-06-13 14:18:01.000000000 +0000 @@ -0,0 +1,87 @@ +From 9e071c9b28c17f347f81b388a003d4eeb3c7a8dd Mon Sep 17 00:00:00 2001 +From: "Miss Islington (bot)" + <31488909+miss-islington@users.noreply.github.com> +Date: Mon, 18 May 2026 19:44:36 +0200 +Subject: [PATCH] [3.13] gh-146211: Reject CR/LF in HTTP tunnel request headers + (GH-146212) (#148343) + +--- python3.13-3.13.5.orig/Lib/http/client.py ++++ python3.13-3.13.5/Lib/http/client.py +@@ -972,13 +972,22 @@ class HTTPConnection: + return ip + + def _tunnel(self): ++ if _contains_disallowed_url_pchar_re.search(self._tunnel_host): ++ raise ValueError('Tunnel host can\'t contain control characters %r' ++ % (self._tunnel_host,)) + connect = b"CONNECT %s:%d %s\r\n" % ( + self._wrap_ipv6(self._tunnel_host.encode("idna")), + self._tunnel_port, + self._http_vsn_str.encode("ascii")) + headers = [connect] + for header, value in self._tunnel_headers.items(): +- headers.append(f"{header}: {value}\r\n".encode("latin-1")) ++ header_bytes = header.encode("latin-1") ++ value_bytes = value.encode("latin-1") ++ if not _is_legal_header_name(header_bytes): ++ raise ValueError('Invalid header name %r' % (header_bytes,)) ++ if _is_illegal_header_value(value_bytes): ++ raise ValueError('Invalid header value %r' % (value_bytes,)) ++ headers.append(b"%s: %s\r\n" % (header_bytes, value_bytes)) + headers.append(b"\r\n") + # Making a single send() call instead of one per line encourages + # the host OS to use a more optimal packet size instead of +--- python3.13-3.13.5.orig/Lib/test/test_httplib.py ++++ python3.13-3.13.5/Lib/test/test_httplib.py +@@ -370,6 +370,51 @@ class HeaderTests(TestCase, ExtraAsserti + with self.assertRaisesRegex(ValueError, 'Invalid header'): + conn.putheader(name, value) + ++ def test_invalid_tunnel_headers(self): ++ cases = ( ++ ('Invalid\r\nName', 'ValidValue'), ++ ('Invalid\rName', 'ValidValue'), ++ ('Invalid\nName', 'ValidValue'), ++ ('\r\nInvalidName', 'ValidValue'), ++ ('\rInvalidName', 'ValidValue'), ++ ('\nInvalidName', 'ValidValue'), ++ (' InvalidName', 'ValidValue'), ++ ('\tInvalidName', 'ValidValue'), ++ ('Invalid:Name', 'ValidValue'), ++ (':InvalidName', 'ValidValue'), ++ ('ValidName', 'Invalid\r\nValue'), ++ ('ValidName', 'Invalid\rValue'), ++ ('ValidName', 'Invalid\nValue'), ++ ('ValidName', 'InvalidValue\r\n'), ++ ('ValidName', 'InvalidValue\r'), ++ ('ValidName', 'InvalidValue\n'), ++ ) ++ for name, value in cases: ++ with self.subTest((name, value)): ++ conn = client.HTTPConnection('example.com') ++ conn.set_tunnel('tunnel', headers={ ++ name: value ++ }) ++ conn.sock = FakeSocket('') ++ with self.assertRaisesRegex(ValueError, 'Invalid header'): ++ conn._tunnel() # Called in .connect() ++ ++ def test_invalid_tunnel_host(self): ++ cases = ( ++ 'invalid\r.host', ++ '\ninvalid.host', ++ 'invalid.host\r\n', ++ 'invalid.host\x00', ++ 'invalid host', ++ ) ++ for tunnel_host in cases: ++ with self.subTest(tunnel_host): ++ conn = client.HTTPConnection('example.com') ++ conn.set_tunnel(tunnel_host) ++ conn.sock = FakeSocket('') ++ with self.assertRaisesRegex(ValueError, 'Tunnel host can\'t contain control characters'): ++ conn._tunnel() # Called in .connect() ++ + def test_headers_debuglevel(self): + body = ( + b'HTTP/1.1 200 OK\r\n' diff -Nru python3.13-3.13.5/debian/patches/CVE-2026-3276.patch python3.13-3.13.5/debian/patches/CVE-2026-3276.patch --- python3.13-3.13.5/debian/patches/CVE-2026-3276.patch 1970-01-01 00:00:00.000000000 +0000 +++ python3.13-3.13.5/debian/patches/CVE-2026-3276.patch 2026-06-13 14:18:01.000000000 +0000 @@ -0,0 +1,231 @@ +From ba785b88add96acbf403d65cb157fb2743a33a32 Mon Sep 17 00:00:00 2001 +From: Petr Viktorin +Date: Tue, 2 Jun 2026 18:12:42 +0200 +Subject: [PATCH] [3.13] gh-149079: Fix O(n^2) canonical ordering in + unicodedata.normalize() (GH-149080) (#150780) + +--- python3.13-3.13.5.orig/Lib/test/test_unicodedata.py ++++ python3.13-3.13.5/Lib/test/test_unicodedata.py +@@ -229,6 +229,34 @@ class UnicodeFunctionsTest(UnicodeDataba + b = 'C\u0338' * 20 + '\xC7' + self.assertEqual(self.db.normalize('NFC', a), b) + ++ def test_long_combining_mark_run(self): ++ # gh-149079: avoid quadratic canonical ordering. ++ payload = "a" + ("\u0300\u0327" * 32) ++ nfd = "a" + ("\u0327" * 32) + ("\u0300" * 32) ++ nfc = "\u00e0" + ("\u0327" * 32) + ("\u0300" * 31) ++ ++ self.assertEqual(self.db.normalize("NFD", payload), nfd) ++ self.assertEqual(self.db.normalize("NFKD", payload), nfd) ++ self.assertEqual(self.db.normalize("NFC", payload), nfc) ++ self.assertEqual(self.db.normalize("NFKC", payload), nfc) ++ ++ def test_combining_mark_run_fast_paths(self): ++ # gh-149079: cover short runs and already-sorted long runs. ++ short_payload = "a" + ("\u0300\u0327" * 9) + "\u0300" ++ short_nfd = "a" + ("\u0327" * 9) + ("\u0300" * 10) ++ short_nfc = "\u00e0" + ("\u0327" * 9) + ("\u0300" * 9) ++ long_sorted = "a" + ("\u0327" * 30) + ("\u0300" * 30) ++ long_sorted_nfc = "\u00e0" + ("\u0327" * 30) + ("\u0300" * 29) ++ ++ self.assertEqual(self.db.normalize("NFD", short_payload), short_nfd) ++ self.assertEqual(self.db.normalize("NFKD", short_payload), short_nfd) ++ self.assertEqual(self.db.normalize("NFC", short_payload), short_nfc) ++ self.assertEqual(self.db.normalize("NFKC", short_payload), short_nfc) ++ self.assertEqual(self.db.normalize("NFD", long_sorted), long_sorted) ++ self.assertEqual(self.db.normalize("NFKD", long_sorted), long_sorted) ++ self.assertEqual(self.db.normalize("NFC", long_sorted), long_sorted_nfc) ++ self.assertEqual(self.db.normalize("NFKC", long_sorted), long_sorted_nfc) ++ + def test_issue29456(self): + # Fix #29456 + u1176_str_a = '\u1100\u1176\u11a8' +--- python3.13-3.13.5.orig/Modules/unicodedata.c ++++ python3.13-3.13.5/Modules/unicodedata.c +@@ -488,19 +488,80 @@ get_decomp_record(PyObject *self, Py_UCS + #define NCount (VCount*TCount) + #define SCount (LCount*NCount) + ++/* Small combining runs are usually cheaper with insertion sort. */ ++#define CANONICAL_ORDERING_COUNTING_SORT_THRESHOLD 20 ++ ++static void ++canonical_ordering_sort_insertion(int kind, void *data, ++ Py_ssize_t start, Py_ssize_t end) ++{ ++ for (Py_ssize_t i = start + 1; i < end; i++) { ++ Py_UCS4 code = PyUnicode_READ(kind, data, i); ++ unsigned char combining = _getrecord_ex(code)->combining; ++ Py_ssize_t j = i; ++ ++ while (j > start) { ++ Py_UCS4 previous = PyUnicode_READ(kind, data, j - 1); ++ if (_getrecord_ex(previous)->combining <= combining) { ++ break; ++ } ++ PyUnicode_WRITE(kind, data, j, previous); ++ j--; ++ } ++ if (j != i) { ++ PyUnicode_WRITE(kind, data, j, code); ++ } ++ } ++} ++ ++static void ++canonical_ordering_sort_counting(int kind, void *data, ++ Py_ssize_t start, Py_ssize_t end, ++ Py_UCS4 *sortbuf) ++{ ++ Py_ssize_t counts[256] = {0}; ++ Py_ssize_t run_length = end - start; ++ Py_ssize_t total = 0; ++ ++ for (Py_ssize_t i = start; i < end; i++) { ++ Py_UCS4 code = PyUnicode_READ(kind, data, i); ++ unsigned char combining = _getrecord_ex(code)->combining; ++ counts[combining]++; ++ } ++ ++ for (size_t i = 0; i < Py_ARRAY_LENGTH(counts); i++) { ++ Py_ssize_t count = counts[i]; ++ counts[i] = total; ++ total += count; ++ } ++ ++ /* Reuse counts[] as the next output slot for each CCC. */ ++ for (Py_ssize_t i = start; i < end; i++) { ++ Py_UCS4 code = PyUnicode_READ(kind, data, i); ++ unsigned char combining = _getrecord_ex(code)->combining; ++ sortbuf[counts[combining]++] = code; ++ } ++ for (Py_ssize_t i = 0; i < run_length; i++) { ++ PyUnicode_WRITE(kind, data, start + i, sortbuf[i]); ++ } ++} ++ + static PyObject* + nfd_nfkd(PyObject *self, PyObject *input, int k) + { + PyObject *result; + Py_UCS4 *output; + Py_ssize_t i, o, osize; +- int kind; +- const void *data; ++ int input_kind, result_kind; ++ const void *input_data; ++ void *result_data; + /* Longest decomposition in Unicode 3.2: U+FDFA */ + Py_UCS4 stack[20]; + Py_ssize_t space, isize; + int index, prefix, count, stackptr; + unsigned char prev, cur; ++ Py_UCS4 *sortbuf = NULL; ++ Py_ssize_t sortbuflen = 0; + + stackptr = 0; + isize = PyUnicode_GET_LENGTH(input); +@@ -520,11 +581,11 @@ nfd_nfkd(PyObject *self, PyObject *input + return NULL; + } + i = o = 0; +- kind = PyUnicode_KIND(input); +- data = PyUnicode_DATA(input); ++ input_kind = PyUnicode_KIND(input); ++ input_data = PyUnicode_DATA(input); + + while (i < isize) { +- stack[stackptr++] = PyUnicode_READ(kind, data, i++); ++ stack[stackptr++] = PyUnicode_READ(input_kind, input_data, i++); + while(stackptr) { + Py_UCS4 code = stack[--stackptr]; + /* Hangul Decomposition adds three characters in +@@ -589,35 +650,66 @@ nfd_nfkd(PyObject *self, PyObject *input + PyMem_Free(output); + if (!result) + return NULL; ++ + /* result is guaranteed to be ready, as it is compact. */ +- kind = PyUnicode_KIND(result); +- data = PyUnicode_DATA(result); ++ result_kind = PyUnicode_KIND(result); ++ result_data = PyUnicode_DATA(result); + +- /* Sort canonically. */ ++ /* Sort each consecutive combining-character run canonically. */ + i = 0; +- prev = _getrecord_ex(PyUnicode_READ(kind, data, i))->combining; +- for (i++; i < PyUnicode_GET_LENGTH(result); i++) { +- cur = _getrecord_ex(PyUnicode_READ(kind, data, i))->combining; +- if (prev == 0 || cur == 0 || prev <= cur) { +- prev = cur; ++ while (i < o) { ++ Py_ssize_t run_length, run_start; ++ int needs_sort = 0; ++ ++ Py_UCS4 ch = PyUnicode_READ(result_kind, result_data, i); ++ prev = _getrecord_ex(ch)->combining; ++ if (prev == 0) { ++ i++; + continue; + } +- /* Non-canonical order. Need to switch *i with previous. */ +- o = i - 1; +- while (1) { +- Py_UCS4 tmp = PyUnicode_READ(kind, data, o+1); +- PyUnicode_WRITE(kind, data, o+1, +- PyUnicode_READ(kind, data, o)); +- PyUnicode_WRITE(kind, data, o, tmp); +- o--; +- if (o < 0) +- break; +- prev = _getrecord_ex(PyUnicode_READ(kind, data, o))->combining; +- if (prev == 0 || prev <= cur) ++ ++ run_start = i++; ++ while (i < o) { ++ Py_UCS4 ch = PyUnicode_READ(result_kind, result_data, i); ++ cur = _getrecord_ex(ch)->combining; ++ if (cur == 0) { + break; ++ } ++ if (prev > cur) { ++ needs_sort = 1; ++ } ++ prev = cur; ++ i++; + } +- prev = _getrecord_ex(PyUnicode_READ(kind, data, i))->combining; ++ if (!needs_sort) { ++ continue; ++ } ++ ++ run_length = i - run_start; ++ if (run_length < CANONICAL_ORDERING_COUNTING_SORT_THRESHOLD) { ++ canonical_ordering_sort_insertion(result_kind, result_data, ++ run_start, i); ++ continue; ++ } ++ ++ if (run_length > sortbuflen) { ++ Py_UCS4 *new_sortbuf = PyMem_Resize(sortbuf, ++ Py_UCS4, ++ run_length); ++ if (new_sortbuf == NULL) { ++ PyErr_NoMemory(); ++ PyMem_Free(sortbuf); ++ Py_DECREF(result); ++ return NULL; ++ } ++ sortbuf = new_sortbuf; ++ sortbuflen = run_length; ++ } ++ ++ canonical_ordering_sort_counting(result_kind, result_data, ++ run_start, i, sortbuf); + } ++ PyMem_Free(sortbuf); + return result; + } + diff -Nru python3.13-3.13.5/debian/patches/CVE-2026-6019-2.patch python3.13-3.13.5/debian/patches/CVE-2026-6019-2.patch --- python3.13-3.13.5/debian/patches/CVE-2026-6019-2.patch 1970-01-01 00:00:00.000000000 +0000 +++ python3.13-3.13.5/debian/patches/CVE-2026-6019-2.patch 2026-06-13 14:18:01.000000000 +0000 @@ -0,0 +1,124 @@ +From e7d4c3ff421916986223690a8425d2383f6f3802 Mon Sep 17 00:00:00 2001 +From: Stan Ulbrych +Date: Mon, 8 Jun 2026 20:15:21 +0100 +Subject: [PATCH] [3.13] gh-149144: Use `decodeURIComponent()` for UTF-8 + support in `js_output()` (GH-149157) (#150949) + +Co-authored-by: Seth Larson +--- + Lib/http/cookies.py | 6 +++--- + Lib/test/test_http_cookies.py | 27 ++++++++++++++------------- + 2 files changed, 17 insertions(+), 16 deletions(-) + +diff --git a/Lib/http/cookies.py b/Lib/http/cookies.py +index aebc2a163e4..2cffa2a9ad6 100644 +--- a/Lib/http/cookies.py ++++ b/Lib/http/cookies.py +@@ -389,18 +389,18 @@ def __repr__(self): + return '<%s: %s>' % (self.__class__.__name__, self.OutputString()) + + def js_output(self, attrs=None): +- import base64 ++ import urllib.parse + # Print javascript + output_string = self.OutputString(attrs) + if _has_control_character(output_string): + raise CookieError("Control characters are not allowed in cookies") + # Base64-encode value to avoid template + # injection in cookie values. +- output_encoded = base64.b64encode(output_string.encode('utf-8')).decode("ascii") ++ output_encoded = urllib.parse.quote(output_string, safe='', encoding='utf-8') + return """ + + """ % (output_encoded,) +diff --git a/Lib/test/test_http_cookies.py b/Lib/test/test_http_cookies.py +index 88914123d51..c48d5d91c2b 100644 +--- a/Lib/test/test_http_cookies.py ++++ b/Lib/test/test_http_cookies.py +@@ -1,10 +1,10 @@ + # Simple test suite for http/cookies.py +-import base64 + import copy + import unittest + import doctest + from http import cookies + import pickle ++import urllib.parse + from test import support + from test.support.testcase import ExtraAssertions + +@@ -153,19 +153,19 @@ def test_load(self): + + self.assertEqual(C.output(['path']), + 'Set-Cookie: Customer="WILE_E_COYOTE"; Path=/acme') +- cookie_encoded = base64.b64encode(b'Customer="WILE_E_COYOTE"; Path=/acme; Version=1').decode('ascii') ++ cookie_encoded = urllib.parse.quote('Customer="WILE_E_COYOTE"; Path=/acme; Version=1', safe='', encoding='utf-8') + self.assertEqual(C.js_output(), fr""" + + """) +- cookie_encoded = base64.b64encode(b'Customer="WILE_E_COYOTE"; Path=/acme').decode('ascii') ++ cookie_encoded = urllib.parse.quote('Customer="WILE_E_COYOTE"; Path=/acme', safe='', encoding='utf-8') + self.assertEqual(C.js_output(['path']), fr""" + + """) +@@ -262,19 +262,19 @@ def test_quoted_meta(self): + + self.assertEqual(C.output(['path']), + 'Set-Cookie: Customer="WILE_E_COYOTE"; Path=/acme') +- expected_encoded_cookie = base64.b64encode(b'Customer=\"WILE_E_COYOTE\"; Path=/acme; Version=1').decode('ascii') ++ expected_encoded_cookie = urllib.parse.quote('Customer=\"WILE_E_COYOTE\"; Path=/acme; Version=1', safe='', encoding='utf-8') + self.assertEqual(C.js_output(), fr""" + + """) +- expected_encoded_cookie = base64.b64encode(b'Customer=\"WILE_E_COYOTE\"; Path=/acme').decode('ascii') ++ expected_encoded_cookie = urllib.parse.quote('Customer=\"WILE_E_COYOTE\"; Path=/acme', safe='', encoding='utf-8') + self.assertEqual(C.js_output(['path']), fr""" + + """) +@@ -365,13 +365,14 @@ def test_setter(self): + self.assertEqual( + M.output(), + "Set-Cookie: %s=%s; Path=/foo" % (i, "%s_coded_val" % i)) +- expected_encoded_cookie = base64.b64encode( +- ("%s=%s; Path=/foo" % (i, "%s_coded_val" % i)).encode("ascii") +- ).decode('ascii') ++ expected_encoded_cookie = urllib.parse.quote( ++ "%s=%s; Path=/foo" % (i, "%s_coded_val" % i), ++ safe='', encoding='utf-8', ++ ) + expected_js_output = """ + + """ % (expected_encoded_cookie,) +-- +2.53.0 + diff -Nru python3.13-3.13.5/debian/patches/CVE-2026-7774.patch python3.13-3.13.5/debian/patches/CVE-2026-7774.patch --- python3.13-3.13.5/debian/patches/CVE-2026-7774.patch 1970-01-01 00:00:00.000000000 +0000 +++ python3.13-3.13.5/debian/patches/CVE-2026-7774.patch 2026-06-13 14:18:01.000000000 +0000 @@ -0,0 +1,225 @@ +From 0478bd83d82b255e0f29f613367a59d261e7eaa2 Mon Sep 17 00:00:00 2001 +From: "Miss Islington (bot)" + <31488909+miss-islington@users.noreply.github.com> +Date: Mon, 11 May 2026 11:58:26 +0200 +Subject: [PATCH] [3.13] gh-149486: tarfile.data_filter: validate written link + target (GH-149487) (GH-149555) + +--- python3.13-3.13.5.orig/Lib/tarfile.py ++++ python3.13-3.13.5/Lib/tarfile.py +@@ -819,16 +819,22 @@ def _get_filtered_attrs(member, dest_pat + if member.islnk() or member.issym(): + if os.path.isabs(member.linkname): + raise AbsoluteLinkError(member) ++ # A link member that resolves to the destination directory itself ++ # would replace it with a (sym)link, redirecting the destination ++ # for all subsequent members. ++ if target_path == dest_path: ++ raise OutsideDestinationError(member, target_path) + normalized = os.path.normpath(member.linkname) + if normalized != member.linkname: + new_attrs['linkname'] = normalized + if member.issym(): +- target_path = os.path.join(dest_path, +- os.path.dirname(name), +- member.linkname) ++ # The symlink is created at `name` with trailing separators ++ # stripped, so its target is relative to the directory ++ # containing that path. ++ link_dir = os.path.dirname(name.rstrip('/' + os.sep)) ++ target_path = os.path.join(dest_path, link_dir, normalized) + else: +- target_path = os.path.join(dest_path, +- member.linkname) ++ target_path = os.path.join(dest_path, normalized) + target_path = os.path.realpath(target_path, + strict=os.path.ALLOW_MISSING) + if os.path.commonpath([target_path, dest_path]) != dest_path: +--- python3.13-3.13.5.orig/Lib/test/test_tarfile.py ++++ python3.13-3.13.5/Lib/test/test_tarfile.py +@@ -3588,6 +3588,39 @@ class TestExtractionFilters(unittest.Tes + # The destination for the extraction, within `outerdir` + destdir = outerdir / 'dest' + ++ @classmethod ++ def setUpClass(cls): ++ # Posix and Windows have different pathname resolution: ++ # either symlink or a '..' component resolve first. ++ # Let's see which we are on. ++ if os_helper.can_symlink(): ++ testpath = os.path.join(TEMPDIR, 'resolution_test') ++ os.mkdir(testpath) ++ ++ # testpath/current links to `.` which is all of: ++ # - `testpath` ++ # - `testpath/current` ++ # - `testpath/current/current` ++ # - etc. ++ os.symlink('.', os.path.join(testpath, 'current')) ++ ++ # we'll test where `testpath/current/../file` ends up ++ with open(os.path.join(testpath, 'current', '..', 'file'), 'w'): ++ pass ++ ++ if os.path.exists(os.path.join(testpath, 'file')): ++ # Windows collapses 'current\..' to '.' first, leaving ++ # 'testpath\file' ++ cls.dotdot_resolves_early = True ++ elif os.path.exists(os.path.join(testpath, '..', 'file')): ++ # Posix resolves 'current' to '.' first, leaving ++ # 'testpath/../file' ++ cls.dotdot_resolves_early = False ++ else: ++ raise AssertionError('Could not determine link resolution') ++ else: ++ cls.dotdot_resolves_early = False ++ + @contextmanager + def check_context(self, tar, filter, *, check_flag=True): + """Extracts `tar` to `self.destdir` and allows checking the result +@@ -3759,10 +3792,19 @@ class TestExtractionFilters(unittest.Tes + + "which is outside the destination") + + with self.check_context(arc.open(), 'data'): +- self.expect_exception( +- tarfile.LinkOutsideDestinationError, +- """'parent' would link to ['"].*outerdir['"], """ +- + "which is outside the destination") ++ if self.dotdot_resolves_early: ++ # 'current/../..' normalises to '..', which is rejected. ++ self.expect_exception( ++ tarfile.LinkOutsideDestinationError, ++ """'parent' would link to ['"].*outerdir['"], """ ++ + "which is outside the destination") ++ else: ++ # 'current/..' normalises to '.'; the rewritten link is ++ # created and 'parent/evil' lands harmlessly inside the ++ # destination. ++ self.expect_file('current', symlink_to='.') ++ self.expect_file('parent', symlink_to='.') ++ self.expect_file('evil') + + else: + # No symlink support. The symlinks are ignored. +@@ -3852,35 +3894,6 @@ class TestExtractionFilters(unittest.Tes + # Test interplaying symlinks + # Inspired by 'dirsymlink2b' in jwilk/traversal-archives + +- # Posix and Windows have different pathname resolution: +- # either symlink or a '..' component resolve first. +- # Let's see which we are on. +- if os_helper.can_symlink(): +- testpath = os.path.join(TEMPDIR, 'resolution_test') +- os.mkdir(testpath) +- +- # testpath/current links to `.` which is all of: +- # - `testpath` +- # - `testpath/current` +- # - `testpath/current/current` +- # - etc. +- os.symlink('.', os.path.join(testpath, 'current')) +- +- # we'll test where `testpath/current/../file` ends up +- with open(os.path.join(testpath, 'current', '..', 'file'), 'w'): +- pass +- +- if os.path.exists(os.path.join(testpath, 'file')): +- # Windows collapses 'current\..' to '.' first, leaving +- # 'testpath\file' +- dotdot_resolves_early = True +- elif os.path.exists(os.path.join(testpath, '..', 'file')): +- # Posix resolves 'current' to '.' first, leaving +- # 'testpath/../file' +- dotdot_resolves_early = False +- else: +- raise AssertionError('Could not determine link resolution') +- + with ArchiveMaker() as arc: + + # `current` links to `.` which is both the destination directory +@@ -3916,7 +3929,7 @@ class TestExtractionFilters(unittest.Tes + + with self.check_context(arc.open(), 'data'): + if os_helper.can_symlink(): +- if dotdot_resolves_early: ++ if self.dotdot_resolves_early: + # Fail when extracting a file outside destination + self.expect_exception( + tarfile.OutsideDestinationError, +@@ -4037,6 +4050,76 @@ class TestExtractionFilters(unittest.Tes + + "destination") + + @symlink_test ++ @os_helper.skip_unless_symlink ++ def test_normpath_realpath_mismatch(self): ++ # The link-target check must validate the value that will actually ++ # be written to disk (the normalised linkname), not the original. ++ # Here 'a' is a symlink to a deep nonexistent path, so realpath() ++ # of 'a/../../...' stays inside the destination while normpath() ++ # collapses 'a/..' lexically and escapes. ++ depth = len(self.destdir.parts) + 5 ++ deep = '/'.join(f'p{i}' for i in range(depth)) ++ sneaky = 'a/' + '../' * depth + 'flag' ++ for kind in 'symlink_to', 'hardlink_to': ++ with self.subTest(kind): ++ with ArchiveMaker() as arc: ++ arc.add('a', symlink_to=deep) ++ arc.add('escape', **{kind: sneaky}) ++ with self.check_context(arc.open(), 'data'): ++ self.expect_exception( ++ tarfile.LinkOutsideDestinationError) ++ ++ @symlink_test ++ @os_helper.skip_unless_symlink ++ def test_symlink_trailing_slash(self): ++ # A trailing slash on a symlink member's name must not cause the ++ # link target to be resolved relative to the wrong directory. ++ with ArchiveMaker() as arc: ++ t = tarfile.TarInfo('x/') ++ t.type = tarfile.SYMTYPE ++ t.linkname = '..' ++ arc.tar_w.addfile(t) ++ arc.add('x/escaped', content='hi') ++ ++ with self.check_context(arc.open(), 'data'): ++ self.expect_exception(tarfile.LinkOutsideDestinationError) ++ ++ @symlink_test ++ @os_helper.skip_unless_symlink ++ def test_link_at_destination(self): ++ # A link member whose name resolves to the destination directory ++ # itself must be rejected: otherwise the destination is replaced ++ # by a symlink and later members can be redirected through it. ++ for name in '', '.', './': ++ with ArchiveMaker() as arc: ++ t = tarfile.TarInfo(name) ++ t.type = tarfile.SYMTYPE ++ t.linkname = '.' ++ arc.tar_w.addfile(t) ++ ++ with self.check_context(arc.open(), 'data'): ++ self.expect_exception(tarfile.OutsideDestinationError) ++ ++ @symlink_test ++ @os_helper.skip_unless_symlink ++ def test_empty_name_symlink_chain(self): ++ # Regression test for a chain of empty-named symlinks that ++ # incrementally redirects the destination outwards. ++ with ArchiveMaker() as arc: ++ for name, target in [('', ''), ('a/', '..'), ++ ('', 'dummy'), ('', 'a'), ++ ('b/', '..'), ++ ('', 'dummy'), ('', 'a/b')]: ++ t = tarfile.TarInfo(name) ++ t.type = tarfile.SYMTYPE ++ t.linkname = target ++ arc.tar_w.addfile(t) ++ arc.add('escaped', content='hi') ++ ++ with self.check_context(arc.open(), 'data'): ++ self.expect_exception(tarfile.FilterError) ++ ++ @symlink_test + def test_deep_symlink(self): + # Test that symlinks and hardlinks inside a directory + # point to the correct file (`target` of size 3). diff -Nru python3.13-3.13.5/debian/patches/CVE-2026-8328.patch python3.13-3.13.5/debian/patches/CVE-2026-8328.patch --- python3.13-3.13.5/debian/patches/CVE-2026-8328.patch 1970-01-01 00:00:00.000000000 +0000 +++ python3.13-3.13.5/debian/patches/CVE-2026-8328.patch 2026-06-13 14:18:01.000000000 +0000 @@ -0,0 +1,79 @@ +From bb3446dda6c49b32e67c11dbbbf221b40be00763 Mon Sep 17 00:00:00 2001 +From: "Miss Islington (bot)" + <31488909+miss-islington@users.noreply.github.com> +Date: Wed, 13 May 2026 19:58:26 +0200 +Subject: [PATCH] [3.13] gh-87451: Apply CVE-2021-4189 PASV fix to + ftplib.ftpcp() (GH-149648) (#149794) + +--- python3.13-3.13.5.orig/Lib/ftplib.py ++++ python3.13-3.13.5/Lib/ftplib.py +@@ -883,7 +883,16 @@ def ftpcp(source, sourcename, target, ta + type = 'TYPE ' + type + source.voidcmd(type) + target.voidcmd(type) +- sourcehost, sourceport = parse227(source.sendcmd('PASV')) ++ # Don't trust the IPv4 address the source server advertises in its PASV ++ # reply: a malicious source could otherwise point the target's data ++ # connection at an arbitrary host (SSRF). A caller that needs the old ++ # behavior can set trust_server_pasv_ipv4_address on the source FTP ++ # object. See FTP.makepasv(), which applies the same rule. ++ untrusted_host, sourceport = parse227(source.sendcmd('PASV')) ++ if source.trust_server_pasv_ipv4_address: ++ sourcehost = untrusted_host ++ else: ++ sourcehost = source.sock.getpeername()[0] + target.sendport(sourcehost, sourceport) + # RFC 959: the user must "listen" [...] BEFORE sending the + # transfer request. +--- python3.13-3.13.5.orig/Lib/test/test_ftplib.py ++++ python3.13-3.13.5/Lib/test/test_ftplib.py +@@ -16,7 +16,7 @@ try: + except ImportError: + ssl = None + +-from unittest import TestCase, skipUnless ++from unittest import mock, TestCase, skipUnless + from test import support + from test.support import requires_subprocess + from test.support import threading_helper +@@ -1145,6 +1145,40 @@ class TestTimeouts(TestCase): + ftp.close() + + ++class TestFtpcpSecurity(TestCase): ++ """ftpcp() must not trust the host a source server advertises in PASV. ++ ++ A malicious source server can otherwise redirect the target server's ++ data connection to an arbitrary host:port (SSRF), so ftpcp() uses the ++ source server's actual peer address instead, the same as FTP.makepasv(). ++ """ ++ ++ def _make_pair(self, *, advertised_host, real_host, trust=False): ++ source = mock.Mock(spec=ftplib.FTP) ++ source.trust_server_pasv_ipv4_address = trust ++ source.sock.getpeername.return_value = (real_host, 21) ++ # PASV replies give the host as comma-separated octets, not dotted. ++ advertised = advertised_host.replace('.', ',') ++ source.sendcmd.side_effect = lambda cmd: ( ++ f'227 Entering Passive Mode ({advertised},1,2).' ++ if cmd == 'PASV' else '150 ok') ++ target = mock.Mock(spec=ftplib.FTP) ++ target.sendcmd.return_value = '150 ok' ++ return source, target ++ ++ def test_ftpcp_ignores_untrusted_pasv_host(self): ++ source, target = self._make_pair(advertised_host='10.0.0.5', ++ real_host='198.51.100.7') ++ ftplib.ftpcp(source, 'a', target, 'b') ++ target.sendport.assert_called_once_with('198.51.100.7', 258) ++ ++ def test_ftpcp_trust_server_pasv_ipv4_address(self): ++ source, target = self._make_pair(advertised_host='10.0.0.5', ++ real_host='198.51.100.7', trust=True) ++ ftplib.ftpcp(source, 'a', target, 'b') ++ target.sendport.assert_called_once_with('10.0.0.5', 258) ++ ++ + class MiscTestCase(TestCase): + def test__all__(self): + not_exported = { diff -Nru python3.13-3.13.5/debian/patches/CVE-2026-9669.patch python3.13-3.13.5/debian/patches/CVE-2026-9669.patch --- python3.13-3.13.5/debian/patches/CVE-2026-9669.patch 1970-01-01 00:00:00.000000000 +0000 +++ python3.13-3.13.5/debian/patches/CVE-2026-9669.patch 2026-06-13 14:18:01.000000000 +0000 @@ -0,0 +1,82 @@ +From 619a12b2e545391dc436b3af79dda22337382a6f Mon Sep 17 00:00:00 2001 +From: "Miss Islington (bot)" + <31488909+miss-islington@users.noreply.github.com> +Date: Mon, 8 Jun 2026 11:55:32 +0200 +Subject: [PATCH] [3.13] gh-150599: Prevent bz2 decompressor reuse after errors + (GH-150600) + +--- python3.13-3.13.5.orig/Lib/test/test_bz2.py ++++ python3.13-3.13.5/Lib/test/test_bz2.py +@@ -1022,6 +1022,21 @@ class BZ2DecompressorTest(BaseTest): + # Previously, a second call could crash due to internal inconsistency + self.assertRaises(Exception, bzd.decompress, self.BAD_DATA * 30) + ++ def test_decompress_after_data_error(self): ++ data = bytes.fromhex( ++ "425a6839314159265359000000000000007fffff000000000000000000000000" ++ "00000000000000000000000000000000000000e0370000000000000000000000" ++ "000000000000000000000000000000000000000000000000000083f3" ++ ) ++ bzd = BZ2Decompressor() ++ with self.assertRaisesRegex(OSError, "Invalid data stream"): ++ bzd.decompress(data) ++ # Previously, a second call could crash due to internal inconsistency ++ self.assertFalse(bzd.needs_input) ++ self.assertFalse(bzd.eof) ++ with self.assertRaisesRegex(ValueError, "previous error"): ++ bzd.decompress(b'\x00' * 18) ++ + @support.refcount_test + def test_refleaks_in___init__(self): + gettotalrefcount = support.get_attribute(sys, 'gettotalrefcount') +--- python3.13-3.13.5.orig/Modules/_bz2module.c ++++ python3.13-3.13.5/Modules/_bz2module.c +@@ -116,6 +116,7 @@ typedef struct { + typedef struct { + PyObject_HEAD + bz_stream bzs; ++ int bzerror; + char eof; /* Py_T_BOOL expects a char */ + PyObject *unused_data; + char needs_input; +@@ -455,8 +456,11 @@ decompress_buf(BZ2Decompressor *d, Py_ss + + d->bzs_avail_in_real += bzs->avail_in; + +- if (catch_bz2_error(bzret)) ++ if (catch_bz2_error(bzret)) { ++ d->bzerror = bzret; ++ d->needs_input = 0; + goto error; ++ } + if (bzret == BZ_STREAM_END) { + d->eof = 1; + break; +@@ -624,10 +628,17 @@ _bz2_BZ2Decompressor_decompress_impl(BZ2 + PyObject *result = NULL; + + ACQUIRE_LOCK(self); +- if (self->eof) ++ if (self->eof) { + PyErr_SetString(PyExc_EOFError, "End of stream already reached"); +- else ++ } ++ else if (self->bzerror) { ++ // Re-entering BZ2_bzDecompress() after an error can write out of bounds. ++ PyErr_SetString(PyExc_ValueError, ++ "Decompressor is unusable after a previous error"); ++ } ++ else { + result = decompress(self, data->buf, data->len, max_length); ++ } + RELEASE_LOCK(self); + return result; + } +@@ -661,6 +672,7 @@ _bz2_BZ2Decompressor_impl(PyTypeObject * + return NULL; + } + ++ self->bzerror = 0; + self->needs_input = 1; + self->bzs_avail_in_real = 0; + self->input_buffer = NULL; diff -Nru python3.13-3.13.5/debian/patches/series python3.13-3.13.5/debian/patches/series --- python3.13-3.13.5/debian/patches/series 2026-05-05 21:05:32.000000000 +0000 +++ python3.13-3.13.5/debian/patches/series 2026-06-13 14:18:01.000000000 +0000 @@ -47,4 +47,13 @@ CVE-2026-3644.patch CVE-2026-4519.patch CVE-2026-6019.patch +CVE-2026-6019-2.patch CVE-2026-6100.patch +ssl-sni-crash.patch +ssl-reference-leak.patch +traverse-managed-dicts.patch +CVE-2026-1502.patch +CVE-2026-3276.patch +CVE-2026-7774.patch +CVE-2026-8328.patch +CVE-2026-9669.patch diff -Nru python3.13-3.13.5/debian/patches/ssl-reference-leak.patch python3.13-3.13.5/debian/patches/ssl-reference-leak.patch --- python3.13-3.13.5/debian/patches/ssl-reference-leak.patch 1970-01-01 00:00:00.000000000 +0000 +++ python3.13-3.13.5/debian/patches/ssl-reference-leak.patch 2026-06-13 14:18:01.000000000 +0000 @@ -0,0 +1,198 @@ +From e102378eca912df8f51c0f2ede75ff3b44248dac Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= + <10796600+picnixz@users.noreply.github.com> +Date: Sun, 24 May 2026 11:43:03 +0200 +Subject: [PATCH] [3.13] gh-142516: fix reference leaks in `ssl.SSLContext` + objects (GH-143685) (GH-145075) (#148371) + +Cherry picked from commits 3a2a686cc45de2fb685ff332b7b914f27f660680 +and 1decc7ee20cf6dce61e07cd8463ed87c1eb5fcd7 with minor amendments. +--- + Lib/test/test_ssl.py | 60 +++++++++++++++++-- + ...-01-11-13-03-32.gh-issue-142516.u7An-s.rst | 2 + + Modules/_ssl.c | 18 ++++-- + 3 files changed, 70 insertions(+), 10 deletions(-) + create mode 100644 Misc/NEWS.d/next/Library/2026-01-11-13-03-32.gh-issue-142516.u7An-s.rst + +--- a/Lib/test/test_ssl.py ++++ b/Lib/test/test_ssl.py +@@ -52,6 +52,16 @@ + IS_OPENSSL_3_0_0 = ssl.OPENSSL_VERSION_INFO >= (3, 0, 0) + PY_SSL_DEFAULT_CIPHERS = sysconfig.get_config_var('PY_SSL_DEFAULT_CIPHERS') + ++HAS_KEYLOG = hasattr(ssl.SSLContext, 'keylog_filename') ++requires_keylog = unittest.skipUnless( ++ HAS_KEYLOG, 'test requires OpenSSL 1.1.1 with keylog callback') ++CAN_SET_KEYLOG = HAS_KEYLOG and os.name != "nt" ++requires_keylog_setter = unittest.skipUnless( ++ CAN_SET_KEYLOG, ++ "cannot set 'keylog_filename' on Windows" ++) ++ ++ + PROTOCOL_TO_TLS_VERSION = {} + for proto, ver in ( + ("PROTOCOL_SSLv3", "SSLv3"), +@@ -295,24 +305,35 @@ + cert_reqs=ssl.CERT_NONE, + ca_certs=None, certfile=None, keyfile=None, + ciphers=None, ++ min_version=None, max_version=None, + ): + if server_side: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + else: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ++ + if check_hostname is None: + if cert_reqs == ssl.CERT_NONE: + context.check_hostname = False + else: + context.check_hostname = check_hostname ++ + if cert_reqs is not None: + context.verify_mode = cert_reqs ++ + if ca_certs is not None: + context.load_verify_locations(ca_certs) + if certfile is not None or keyfile is not None: + context.load_cert_chain(certfile, keyfile) ++ + if ciphers is not None: + context.set_ciphers(ciphers) ++ ++ if min_version is not None: ++ context.minimum_version = min_version ++ if max_version is not None: ++ context.maximum_version = max_version ++ + return context + + +@@ -324,6 +345,7 @@ + cert_reqs=ssl.CERT_NONE, + ca_certs=None, certfile=None, keyfile=None, + ciphers=None, ++ min_version=None, max_version=None, + **kwargs, + ): + context = make_test_context( +@@ -332,6 +354,7 @@ + cert_reqs=cert_reqs, + ca_certs=ca_certs, certfile=certfile, keyfile=keyfile, + ciphers=ciphers, ++ min_version=min_version, max_version=max_version, + ) + if not server_side: + kwargs.setdefault("server_hostname", SIGNED_CERTFILE_HOSTNAME) +@@ -1756,6 +1779,39 @@ + with self.assertRaises(ValueError): + ctx.num_tickets = 1 + ++ @support.cpython_only ++ def test_refcycle_msg_callback(self): ++ # See https://github.com/python/cpython/issues/142516. ++ ctx = make_test_context() ++ def msg_callback(*args, _=ctx, **kwargs): ... ++ ctx._msg_callback = msg_callback ++ ++ @support.cpython_only ++ @requires_keylog_setter ++ def test_refcycle_keylog_filename(self): ++ # See https://github.com/python/cpython/issues/142516. ++ self.addCleanup(os_helper.unlink, os_helper.TESTFN) ++ ctx = make_test_context() ++ class KeylogFilename(str): ... ++ ctx.keylog_filename = KeylogFilename(os_helper.TESTFN) ++ ctx.keylog_filename._ = ctx ++ ++ @support.cpython_only ++ @unittest.skipUnless(ssl.HAS_PSK, 'requires TLS-PSK') ++ def test_refcycle_psk_client_callback(self): ++ # See https://github.com/python/cpython/issues/142516. ++ ctx = make_test_context() ++ def psk_client_callback(*args, _=ctx, **kwargs): ... ++ ctx.set_psk_client_callback(psk_client_callback) ++ ++ @support.cpython_only ++ @unittest.skipUnless(ssl.HAS_PSK, 'requires TLS-PSK') ++ def test_refcycle_psk_server_callback(self): ++ # See https://github.com/python/cpython/issues/142516. ++ ctx = make_test_context(server_side=True) ++ def psk_server_callback(*args, _=ctx, **kwargs): ... ++ ctx.set_psk_server_callback(psk_server_callback) ++ + + class SSLErrorTests(unittest.TestCase): + +@@ -4967,10 +5023,6 @@ + self.assertEqual(res, b'\x02\n') + + +-HAS_KEYLOG = hasattr(ssl.SSLContext, 'keylog_filename') +-requires_keylog = unittest.skipUnless( +- HAS_KEYLOG, 'test requires OpenSSL 1.1.1 with keylog callback') +- + class TestSSLDebug(unittest.TestCase): + + def keylog_lines(self, fname=os_helper.TESTFN): +--- /dev/null ++++ b/Misc/NEWS.d/next/Library/2026-01-11-13-03-32.gh-issue-142516.u7An-s.rst +@@ -0,0 +1,2 @@ ++:mod:`ssl`: fix reference leaks in :class:`ssl.SSLContext` objects. Patch by ++Bénédikt Tran. +--- a/Modules/_ssl.c ++++ b/Modules/_ssl.c +@@ -297,7 +297,7 @@ + int post_handshake_auth; + #endif + PyObject *msg_cb; +- PyObject *keylog_filename; ++ PyObject *keylog_filename; // can be anything accepted by Py_fopen() + BIO *keylog_bio; + /* Cached module state, also used in SSLSocket and SSLSession code. */ + _sslmodulestate *state; +@@ -322,7 +322,7 @@ + PySSLContext *ctx; /* weakref to SSL context */ + char shutdown_seen_zero; + enum py_ssl_server_or_client socket_type; +- PyObject *owner; /* Python level "owner" passed to servername callback */ ++ PyObject *owner; /* weakref to Python level "owner" passed to servername callback */ + PyObject *server_hostname; + _PySSLError err; /* last seen error from various sources */ + /* Some SSL callbacks don't have error reporting. Callback wrappers +@@ -2290,6 +2290,10 @@ + static int + PySSL_clear(PySSLSocket *self) + { ++ Py_CLEAR(self->Socket); ++ Py_CLEAR(self->ctx); ++ Py_CLEAR(self->owner); ++ Py_CLEAR(self->server_hostname); + Py_CLEAR(self->exc); + return 0; + } +@@ -2313,10 +2317,7 @@ + SSL_set_shutdown(self->ssl, SSL_SENT_SHUTDOWN | SSL_get_shutdown(self->ssl)); + SSL_free(self->ssl); + } +- Py_XDECREF(self->Socket); +- Py_XDECREF(self->ctx); +- Py_XDECREF(self->server_hostname); +- Py_XDECREF(self->owner); ++ (void)PySSL_clear(self); + PyObject_GC_Del(self); + Py_DECREF(tp); + } +@@ -3245,6 +3246,11 @@ + { + Py_VISIT(self->set_sni_cb); + Py_VISIT(self->msg_cb); ++ Py_VISIT(self->keylog_filename); ++#ifndef OPENSSL_NO_PSK ++ Py_VISIT(self->psk_client_callback); ++ Py_VISIT(self->psk_server_callback); ++#endif + Py_VISIT(Py_TYPE(self)); + return 0; + } diff -Nru python3.13-3.13.5/debian/patches/ssl-sni-crash.patch python3.13-3.13.5/debian/patches/ssl-sni-crash.patch --- python3.13-3.13.5/debian/patches/ssl-sni-crash.patch 1970-01-01 00:00:00.000000000 +0000 +++ python3.13-3.13.5/debian/patches/ssl-sni-crash.patch 2026-06-13 14:18:01.000000000 +0000 @@ -0,0 +1,184 @@ +From 59f33e82ff8f530cfe589ca47a6fdff538d1a1d3 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= + <10796600+picnixz@users.noreply.github.com> +Date: Sun, 29 Mar 2026 15:07:15 +0200 +Subject: [PATCH] [3.13] gh-146080: fix a crash in SNI callbacks when the SSL + object is gone (GH-146573) (#146598) + +(cherry picked from commit 24db78c5329dd405460bfdf76df380ced6231353) +--- + Lib/test/test_ssl.py | 107 ++++++++++++++++-- + ...-03-28-13-19-20.gh-issue-146080.srN12a.rst | 2 + + Modules/_ssl.c | 2 +- + 3 files changed, 100 insertions(+), 11 deletions(-) + create mode 100644 Misc/NEWS.d/next/Library/2026-03-28-13-19-20.gh-issue-146080.srN12a.rst + +--- a/Lib/test/test_ssl.py ++++ b/Lib/test/test_ssl.py +@@ -1,5 +1,6 @@ + # Test the support for SSL and sockets + ++import contextlib + import sys + import unittest + import unittest.mock +@@ -47,6 +48,7 @@ + + PROTOCOLS = sorted(ssl._PROTOCOL_NAMES) + HOST = socket_helper.HOST ++IS_AWS_LC = "AWS-LC" in ssl.OPENSSL_VERSION + IS_OPENSSL_3_0_0 = ssl.OPENSSL_VERSION_INFO >= (3, 0, 0) + PY_SSL_DEFAULT_CIPHERS = sysconfig.get_config_var('PY_SSL_DEFAULT_CIPHERS') + +@@ -286,18 +288,24 @@ + ) + + +-def test_wrap_socket(sock, *, +- cert_reqs=ssl.CERT_NONE, ca_certs=None, +- ciphers=None, certfile=None, keyfile=None, +- **kwargs): +- if not kwargs.get("server_side"): +- kwargs["server_hostname"] = SIGNED_CERTFILE_HOSTNAME +- context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) +- else: ++def make_test_context( ++ *, ++ server_side=False, ++ check_hostname=None, ++ cert_reqs=ssl.CERT_NONE, ++ ca_certs=None, certfile=None, keyfile=None, ++ ciphers=None, ++): ++ if server_side: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +- if cert_reqs is not None: ++ else: ++ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ++ if check_hostname is None: + if cert_reqs == ssl.CERT_NONE: + context.check_hostname = False ++ else: ++ context.check_hostname = check_hostname ++ if cert_reqs is not None: + context.verify_mode = cert_reqs + if ca_certs is not None: + context.load_verify_locations(ca_certs) +@@ -305,7 +313,29 @@ + context.load_cert_chain(certfile, keyfile) + if ciphers is not None: + context.set_ciphers(ciphers) +- return context.wrap_socket(sock, **kwargs) ++ return context ++ ++ ++def test_wrap_socket( ++ sock, ++ *, ++ server_side=False, ++ check_hostname=None, ++ cert_reqs=ssl.CERT_NONE, ++ ca_certs=None, certfile=None, keyfile=None, ++ ciphers=None, ++ **kwargs, ++): ++ context = make_test_context( ++ server_side=server_side, ++ check_hostname=check_hostname, ++ cert_reqs=cert_reqs, ++ ca_certs=ca_certs, certfile=certfile, keyfile=keyfile, ++ ciphers=ciphers, ++ ) ++ if not server_side: ++ kwargs.setdefault("server_hostname", SIGNED_CERTFILE_HOSTNAME) ++ return context.wrap_socket(sock, server_side=server_side, **kwargs) + + + USE_SAME_TEST_CONTEXT = False +@@ -345,6 +375,20 @@ + return client_context, server_context, hostname + + ++def do_ssl_object_handshake(sslobject, outgoing, max_retry=25): ++ """Call do_handshake() on the sslobject and return the sent data. ++ ++ If do_handshake() fails more than *max_retry* times, return None. ++ """ ++ data, attempt = None, 0 ++ while not data and attempt < max_retry: ++ with contextlib.suppress(ssl.SSLWantReadError): ++ sslobject.do_handshake() ++ data = outgoing.read() ++ attempt += 1 ++ return data ++ ++ + class BasicSocketTests(unittest.TestCase): + + def test_constants(self): +@@ -1415,6 +1459,49 @@ + ctx.set_servername_callback(None) + ctx.set_servername_callback(dummycallback) + ++ def test_sni_callback_on_dead_references(self): ++ # See https://github.com/python/cpython/issues/146080. ++ c_ctx = make_test_context() ++ c_inc, c_out = ssl.MemoryBIO(), ssl.MemoryBIO() ++ client = c_ctx.wrap_bio(c_inc, c_out, server_hostname=SIGNED_CERTFILE_HOSTNAME) ++ ++ def sni_callback(sock, servername, ctx): pass ++ sni_callback = unittest.mock.Mock(wraps=sni_callback) ++ s_ctx = make_test_context(server_side=True, certfile=SIGNED_CERTFILE) ++ s_ctx.set_servername_callback(sni_callback) ++ ++ s_inc, s_out = ssl.MemoryBIO(), ssl.MemoryBIO() ++ server = s_ctx.wrap_bio(s_inc, s_out, server_side=True) ++ server_impl = server._sslobj ++ ++ # Perform the handshake on the client side first. ++ data = do_ssl_object_handshake(client, c_out) ++ sni_callback.assert_not_called() ++ if data is None: ++ self.skipTest("cannot establish a handshake from the client") ++ s_inc.write(data) ++ sni_callback.assert_not_called() ++ # Delete the server object before it starts doing its handshake ++ # and ensure that we did not call the SNI callback yet. ++ del server ++ gc.collect() ++ # Try to continue the server's handshake by directly using ++ # the internal SSL object. The latter is a weak reference ++ # stored in the server context and has now a dead owner. ++ with self.assertRaises(ssl.SSLError) as cm: ++ server_impl.do_handshake() ++ # The SNI C callback raised an exception before calling our callback. ++ sni_callback.assert_not_called() ++ ++ # In AWS-LC, any handshake failures reports SSL_R_PARSE_TLSEXT, ++ # while OpenSSL uses SSL_R_CALLBACK_FAILED on SNI callback failures. ++ if IS_AWS_LC: ++ libssl_error_reason = "PARSE_TLSEXT" ++ else: ++ libssl_error_reason = "callback failed" ++ self.assertIn(libssl_error_reason, str(cm.exception)) ++ self.assertEqual(cm.exception.errno, ssl.SSL_ERROR_SSL) ++ + def test_sni_callback_refcycle(self): + # Reference cycles through the servername callback are detected + # and cleared. +--- /dev/null ++++ b/Misc/NEWS.d/next/Library/2026-03-28-13-19-20.gh-issue-146080.srN12a.rst +@@ -0,0 +1,2 @@ ++:mod:`ssl`: fix a crash when an SNI callback tries to use an SSL object that ++has already been garbage-collected. Patch by Bénédikt Tran. +--- a/Modules/_ssl.c ++++ b/Modules/_ssl.c +@@ -4681,7 +4681,7 @@ + return ret; + + error: +- Py_DECREF(ssl_socket); ++ Py_XDECREF(ssl_socket); + *al = SSL_AD_INTERNAL_ERROR; + ret = SSL_TLSEXT_ERR_ALERT_FATAL; + PyGILState_Release(gstate); diff -Nru python3.13-3.13.5/debian/patches/traverse-managed-dicts.patch python3.13-3.13.5/debian/patches/traverse-managed-dicts.patch --- python3.13-3.13.5/debian/patches/traverse-managed-dicts.patch 1970-01-01 00:00:00.000000000 +0000 +++ python3.13-3.13.5/debian/patches/traverse-managed-dicts.patch 2026-06-13 14:18:01.000000000 +0000 @@ -0,0 +1,90 @@ +From 702d08578394a387ad5099befe79acf6615cb27e Mon Sep 17 00:00:00 2001 +From: Sam Gross +Date: Mon, 2 Mar 2026 15:03:08 -0500 +Subject: [PATCH] [3.13] gh-130327: Always traverse managed dictionaries, even + when inline values are available (GH-130469) (#145440) + +Co-authored-by: Peter Bierma +Bug-Debian: https://bugs.debian.org/1108039 +Origin: upstream, https://github.com/python/cpython/pull/145440 +--- + Lib/test/test_dict.py | 19 +++++++++++++++++++ + ...-02-19-21-06-30.gh-issue-130327.z3TaR8.rst | 2 ++ + Objects/dictobject.c | 17 ++++++++++------- + 3 files changed, 31 insertions(+), 7 deletions(-) + create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2025-02-19-21-06-30.gh-issue-130327.z3TaR8.rst + +--- a/Lib/test/test_dict.py ++++ b/Lib/test/test_dict.py +@@ -1621,6 +1621,26 @@ + self.assertEqual(d.get(key3_3), 44) + self.assertGreaterEqual(eq_count, 1) + ++ def test_overwrite_managed_dict(self): ++ # GH-130327: Overwriting an object's managed dictionary with another object's ++ # skipped traversal in favor of inline values, causing the GC to believe that ++ # the __dict__ wasn't reachable. ++ import gc ++ ++ class Shenanigans: ++ pass ++ ++ to_be_deleted = Shenanigans() ++ to_be_deleted.attr = "whatever" ++ holds_reference = Shenanigans() ++ holds_reference.__dict__ = to_be_deleted.__dict__ ++ holds_reference.ref = {"circular": to_be_deleted, "data": 42} ++ ++ del to_be_deleted ++ gc.collect() ++ self.assertEqual(holds_reference.ref['data'], 42) ++ self.assertEqual(holds_reference.attr, "whatever") ++ + + class CAPITest(unittest.TestCase): + +--- /dev/null ++++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-02-19-21-06-30.gh-issue-130327.z3TaR8.rst +@@ -0,0 +1,2 @@ ++Fix erroneous clearing of an object's :attr:`~object.__dict__` if ++overwritten at runtime. +--- a/Objects/dictobject.c ++++ b/Objects/dictobject.c +@@ -4553,10 +4553,8 @@ + + if (DK_IS_UNICODE(keys)) { + if (_PyDict_HasSplitTable(mp)) { +- if (!mp->ma_values->embedded) { +- for (i = 0; i < n; i++) { +- Py_VISIT(mp->ma_values->values[i]); +- } ++ for (i = 0; i < n; i++) { ++ Py_VISIT(mp->ma_values->values[i]); + } + } + else { +@@ -7121,16 +7119,21 @@ + if((tp->tp_flags & Py_TPFLAGS_MANAGED_DICT) == 0) { + return 0; + } +- if (tp->tp_flags & Py_TPFLAGS_INLINE_VALUES) { ++ PyDictObject *dict = _PyObject_ManagedDictPointer(obj)->dict; ++ if (dict != NULL) { ++ // GH-130327: If there's a managed dictionary available, we should ++ // *always* traverse it. The dict is responsible for traversing the ++ // inline values if it points to them. ++ Py_VISIT(dict); ++ } ++ else if (tp->tp_flags & Py_TPFLAGS_INLINE_VALUES) { + PyDictValues *values = _PyObject_InlineValues(obj); + if (values->valid) { + for (Py_ssize_t i = 0; i < values->capacity; i++) { + Py_VISIT(values->values[i]); + } +- return 0; + } + } +- Py_VISIT(_PyObject_ManagedDictPointer(obj)->dict); + return 0; + } +