Hi Arnaud (2026.06.10_03:35:24_-0400)

Apparently the fix for CVE-2026-6019 got a follow-up commit: https://github.com/python/cpython/issues/149144

Incorporated that and Moritz' changes, in git. DebDiff attached.

Testing in Debusine now: 
https://debusine.debian.net/debian/developers/work-request/833833/

3.13.14 finally came out and I've got that testing in Debusine too.

Stefano

--
Stefano Rivera
  http://tumbleweed.org.za/
  +1 415 683 3272
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 17:05:52.000000000 -0400
+++ python3.13-3.13.5/debian/changelog  2026-05-30 15:24:01.000000000 -0400
@@ -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 <[email protected]>  Sat, 30 May 2026 15:24: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        1969-12-31 
20:00:00.000000000 -0400
+++ python3.13-3.13.5/debian/patches/CVE-2026-1502.patch        2026-05-30 
15:24:01.000000000 -0400
@@ -0,0 +1,87 @@
+From 9e071c9b28c17f347f81b388a003d4eeb3c7a8dd Mon Sep 17 00:00:00 2001
+From: "Miss Islington (bot)"
+ <[email protected]>
+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        1969-12-31 
20:00:00.000000000 -0400
+++ python3.13-3.13.5/debian/patches/CVE-2026-3276.patch        2026-05-30 
15:24:01.000000000 -0400
@@ -0,0 +1,231 @@
+From ba785b88add96acbf403d65cb157fb2743a33a32 Mon Sep 17 00:00:00 2001
+From: Petr Viktorin <[email protected]>
+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      1969-12-31 
20:00:00.000000000 -0400
+++ python3.13-3.13.5/debian/patches/CVE-2026-6019-2.patch      2026-05-30 
15:24:01.000000000 -0400
@@ -0,0 +1,124 @@
+From e7d4c3ff421916986223690a8425d2383f6f3802 Mon Sep 17 00:00:00 2001
+From: Stan Ulbrych <[email protected]>
+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 <[email protected]>
+---
+ 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 """
+         <script type="text/javascript">
+         <!-- begin hiding
+-        document.cookie = atob(\"%s\");
++        document.cookie = decodeURIComponent(\"%s\");
+         // end hiding -->
+         </script>
+         """ % (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"""
+         <script type="text/javascript">
+         <!-- begin hiding
+-        document.cookie = atob("{cookie_encoded}");
++        document.cookie = decodeURIComponent("{cookie_encoded}");
+         // end hiding -->
+         </script>
+         """)
+-        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"""
+         <script type="text/javascript">
+         <!-- begin hiding
+-        document.cookie = atob("{cookie_encoded}");
++        document.cookie = decodeURIComponent("{cookie_encoded}");
+         // end hiding -->
+         </script>
+         """)
+@@ -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"""
+         <script type="text/javascript">
+         <!-- begin hiding
+-        document.cookie = atob("{expected_encoded_cookie}");
++        document.cookie = decodeURIComponent("{expected_encoded_cookie}");
+         // end hiding -->
+         </script>
+         """)
+-        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"""
+         <script type="text/javascript">
+         <!-- begin hiding
+-        document.cookie = atob("{expected_encoded_cookie}");
++        document.cookie = decodeURIComponent("{expected_encoded_cookie}");
+         // end hiding -->
+         </script>
+         """)
+@@ -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 = """
+         <script type="text/javascript">
+         <!-- begin hiding
+-        document.cookie = atob("%s");
++        document.cookie = decodeURIComponent("%s");
+         // end hiding -->
+         </script>
+         """ % (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        1969-12-31 
20:00:00.000000000 -0400
+++ python3.13-3.13.5/debian/patches/CVE-2026-7774.patch        2026-05-30 
15:24:01.000000000 -0400
@@ -0,0 +1,225 @@
+From 0478bd83d82b255e0f29f613367a59d261e7eaa2 Mon Sep 17 00:00:00 2001
+From: "Miss Islington (bot)"
+ <[email protected]>
+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        1969-12-31 
20:00:00.000000000 -0400
+++ python3.13-3.13.5/debian/patches/CVE-2026-8328.patch        2026-05-30 
15:24:01.000000000 -0400
@@ -0,0 +1,79 @@
+From bb3446dda6c49b32e67c11dbbbf221b40be00763 Mon Sep 17 00:00:00 2001
+From: "Miss Islington (bot)"
+ <[email protected]>
+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        1969-12-31 
20:00:00.000000000 -0400
+++ python3.13-3.13.5/debian/patches/CVE-2026-9669.patch        2026-05-30 
15:24:01.000000000 -0400
@@ -0,0 +1,82 @@
+From 619a12b2e545391dc436b3af79dda22337382a6f Mon Sep 17 00:00:00 2001
+From: "Miss Islington (bot)"
+ <[email protected]>
+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 17:05:32.000000000 
-0400
+++ python3.13-3.13.5/debian/patches/series     2026-05-30 15:24:01.000000000 
-0400
@@ -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   1969-12-31 
20:00:00.000000000 -0400
+++ python3.13-3.13.5/debian/patches/ssl-reference-leak.patch   2026-05-30 
15:24:01.000000000 -0400
@@ -0,0 +1,198 @@
+From e102378eca912df8f51c0f2ede75ff3b44248dac Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?=
+ <[email protected]>
+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        1969-12-31 
20:00:00.000000000 -0400
+++ python3.13-3.13.5/debian/patches/ssl-sni-crash.patch        2026-05-30 
15:24:01.000000000 -0400
@@ -0,0 +1,184 @@
+From 59f33e82ff8f530cfe589ca47a6fdff538d1a1d3 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?=
+ <[email protected]>
+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       
1969-12-31 20:00:00.000000000 -0400
+++ python3.13-3.13.5/debian/patches/traverse-managed-dicts.patch       
2026-05-30 15:24:01.000000000 -0400
@@ -0,0 +1,90 @@
+From 702d08578394a387ad5099befe79acf6615cb27e Mon Sep 17 00:00:00 2001
+From: Sam Gross <[email protected]>
+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 <[email protected]>
+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;
+ }
+ 

Reply via email to