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