Package: release.debian.org
Severity: normal
Tags: trixie
X-Debbugs-Cc: [email protected], [email protected]
Control: affects -1 + src:python3.13
User: [email protected]
Usertags: pu

This update fixes five low impact security issues in Python, all
patches were cherrypicked from the upstream 3.13 branch. All tests
triggered via debusine look good. Debdiff below.

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 23:05:52.000000000 +0200
+++ python3.13-3.13.5/debian/changelog  2026-06-08 22:58:08.000000000 +0200
@@ -1,3 +1,13 @@
+python3.13 (3.13.5-2+deb13u3) trixie; urgency=medium
+
+  * CVE-2026-1502
+  * CVE-2026-3276
+  * CVE-2026-7774
+  * CVE-2026-8328
+  * CVE-2026-9669
+
+ -- Moritz Mühlenhoff <[email protected]>  Mon, 08 Jun 2026 22:58:08 +0200
+
 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 
01:00:00.000000000 +0100
+++ python3.13-3.13.5/debian/patches/CVE-2026-1502.patch        2026-06-08 
22:55:25.000000000 +0200
@@ -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        1970-01-01 
01:00:00.000000000 +0100
+++ python3.13-3.13.5/debian/patches/CVE-2026-3276.patch        2026-06-08 
22:56:22.000000000 +0200
@@ -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-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 
01:00:00.000000000 +0100
+++ python3.13-3.13.5/debian/patches/CVE-2026-7774.patch        2026-06-08 
22:57:15.000000000 +0200
@@ -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        1970-01-01 
01:00:00.000000000 +0100
+++ python3.13-3.13.5/debian/patches/CVE-2026-8328.patch        2026-06-08 
22:58:04.000000000 +0200
@@ -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        1970-01-01 
01:00:00.000000000 +0100
+++ python3.13-3.13.5/debian/patches/CVE-2026-9669.patch        2026-06-08 
22:58:08.000000000 +0200
@@ -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 23:05:32.000000000 
+0200
+++ python3.13-3.13.5/debian/patches/series     2026-06-08 22:58:08.000000000 
+0200
@@ -48,3 +48,8 @@
 CVE-2026-4519.patch
 CVE-2026-6019.patch
 CVE-2026-6100.patch
+CVE-2026-1502.patch
+CVE-2026-3276.patch
+CVE-2026-7774.patch
+CVE-2026-8328.patch
+CVE-2026-9669.patch

Reply via email to