Submitted by:            Douglas R. Reno <renodr at linuxfromscratch dot org>
Date:                    2026-05-14
Initial Package Version: 3.14.5
Upstream Status:         Applied
Origin:                  Upstream PRs 149023 and 149793
Description:             Fixes CVE-2026-7210 and CVE-2026-8328 in Python-3.14.5.
                         These are in the built-in XML functionality (the
                         xml.parsers.expat and xml.etree.ElementTree functions),
                         and the ftpcp() function in the ftplib module. The XML
                         vulnerability is due to the usage of insufficient
                         entropy, while the FTP vulnerability allows for trivial
                         redirection of FTP requests.

diff -Naurp Python-3.14.5.orig/Include/internal/pycore_pyhash.h Python-3.14.5/Include/internal/pycore_pyhash.h
--- Python-3.14.5.orig/Include/internal/pycore_pyhash.h	2026-05-10 05:21:34.000000000 -0500
+++ Python-3.14.5/Include/internal/pycore_pyhash.h	2026-05-14 09:38:19.879178508 -0500
@@ -27,14 +27,14 @@ _Py_HashPointerRaw(const void *ptr)
  *   pppppppp ssssssss ........  fnv -- two Py_hash_t
  *   k0k0k0k0 k1k1k1k1 ........  siphash -- two uint64_t
  *   ........ ........ ssssssss  djbx33a -- 16 bytes padding + one Py_hash_t
- *   ........ ........ eeeeeeee  pyexpat XML hash salt
+ *   eeeeeeee eeeeeeee eeeeeeee  pyexpat XML hash salt
  *
  * memory layout on 32 bit systems
  *   cccccccc cccccccc cccccccc  uc
  *   ppppssss ........ ........  fnv -- two Py_hash_t
  *   k0k0k0k0 k1k1k1k1 ........  siphash -- two uint64_t (*)
  *   ........ ........ ssss....  djbx33a -- 16 bytes padding + one Py_hash_t
- *   ........ ........ eeee....  pyexpat XML hash salt
+ *   eeeeeeee eeeeeeee eeee....  pyexpat XML hash salt
  *
  * (*) The siphash member may not be available on 32 bit platforms without
  *     an unsigned int64 data type.
@@ -58,7 +58,9 @@ typedef union {
         Py_hash_t suffix;
     } djbx33a;
     struct {
-        unsigned char padding[16];
+        /* 16 bytes for XML_SetHashSalt16Bytes */
+        uint8_t hashsalt16[16];
+        /* 4/8 bytes for legacy XML_SetHashSalt */
         Py_hash_t hashsalt;
     } expat;
 } _Py_HashSecret_t;
diff -Naurp Python-3.14.5.orig/Include/pyexpat.h Python-3.14.5/Include/pyexpat.h
--- Python-3.14.5.orig/Include/pyexpat.h	2026-05-10 05:21:34.000000000 -0500
+++ Python-3.14.5/Include/pyexpat.h	2026-05-14 09:38:19.879231188 -0500
@@ -57,6 +57,9 @@ struct PyExpat_CAPI
         XML_Parser parser, unsigned long long activationThresholdBytes);
     XML_Bool (*SetAllocTrackerMaximumAmplification)(
         XML_Parser parser, float maxAmplificationFactor);
+   /* might be NULL for expat < 2.8.0 */
+    XML_Bool (*SetHashSalt16Bytes)(
+        XML_Parser parser, const uint8_t entropy[16]);
     /* always add new stuff to the end! */
 };
 
diff -Naurp Python-3.14.5.orig/Lib/ftplib.py Python-3.14.5/Lib/ftplib.py
--- Python-3.14.5.orig/Lib/ftplib.py	2026-05-10 05:21:34.000000000 -0500
+++ Python-3.14.5/Lib/ftplib.py	2026-05-14 09:38:22.405916354 -0500
@@ -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.
diff -Naurp Python-3.14.5.orig/Lib/test/test_ftplib.py Python-3.14.5/Lib/test/test_ftplib.py
--- Python-3.14.5.orig/Lib/test/test_ftplib.py	2026-05-10 05:21:34.000000000 -0500
+++ Python-3.14.5/Lib/test/test_ftplib.py	2026-05-14 09:38:22.406051270 -0500
@@ -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 -Naurp Python-3.14.5.orig/Misc/NEWS.d/next/Security/2026-04-26-19-30-45.gh-issue-149018.a9SqWb.rst Python-3.14.5/Misc/NEWS.d/next/Security/2026-04-26-19-30-45.gh-issue-149018.a9SqWb.rst
--- Python-3.14.5.orig/Misc/NEWS.d/next/Security/2026-04-26-19-30-45.gh-issue-149018.a9SqWb.rst	1969-12-31 18:00:00.000000000 -0600
+++ Python-3.14.5/Misc/NEWS.d/next/Security/2026-04-26-19-30-45.gh-issue-149018.a9SqWb.rst	2026-05-14 09:38:19.879313184 -0500
@@ -0,0 +1,3 @@
+Improved protection against XML hash-flooding attacks in
+:mod:`xml.parsers.expat` and :mod:`xml.etree.ElementTree` when Python is
+compiled with libExpat 2.8.0 or later.
diff -Naurp Python-3.14.5.orig/Misc/NEWS.d/next/Security/2026-05-10-18-05-32.gh-issue-87451.XkKB6M.rst Python-3.14.5/Misc/NEWS.d/next/Security/2026-05-10-18-05-32.gh-issue-87451.XkKB6M.rst
--- Python-3.14.5.orig/Misc/NEWS.d/next/Security/2026-05-10-18-05-32.gh-issue-87451.XkKB6M.rst	1969-12-31 18:00:00.000000000 -0600
+++ Python-3.14.5/Misc/NEWS.d/next/Security/2026-05-10-18-05-32.gh-issue-87451.XkKB6M.rst	2026-05-14 09:38:22.406124500 -0500
@@ -0,0 +1,6 @@
+The :mod:`ftplib` module's undocumented ``ftpcp`` function no longer trusts
+the IPv4 address value returned from the source server in response to the
+``PASV`` command by default, completing the fix for CVE-2021-4189.  As with
+:class:`ftplib.FTP`, the former behavior can be re-enabled by setting the
+``trust_server_pasv_ipv4_address`` attribute on the source :class:`ftplib.FTP`
+instance to ``True``.  Thanks to Qi Deng at Aurascape AI for the report.
diff -Naurp Python-3.14.5.orig/Modules/_elementtree.c Python-3.14.5/Modules/_elementtree.c
--- Python-3.14.5.orig/Modules/_elementtree.c	2026-05-10 05:21:34.000000000 -0500
+++ Python-3.14.5/Modules/_elementtree.c	2026-05-14 09:38:19.879483808 -0500
@@ -3724,8 +3724,12 @@ _elementtree_XMLParser___init___impl(XML
         PyErr_NoMemory();
         return -1;
     }
-    /* expat < 2.1.0 has no XML_SetHashSalt() */
-    if (EXPAT(st, SetHashSalt) != NULL) {
+    // Prefer 16-byte entropy, only expat >= 2.8.0. See gh-149018
+    if (EXPAT(st, SetHashSalt16Bytes) != NULL) {
+        EXPAT(st, SetHashSalt16Bytes)(self->parser,
+                                      _Py_HashSecret.expat.hashsalt16);
+    }
+    else if (EXPAT(st, SetHashSalt) != NULL) {
         EXPAT(st, SetHashSalt)(self->parser,
                            (unsigned long)_Py_HashSecret.expat.hashsalt);
     }
diff -Naurp Python-3.14.5.orig/Modules/pyexpat.c Python-3.14.5/Modules/pyexpat.c
--- Python-3.14.5.orig/Modules/pyexpat.c	2026-05-10 05:21:34.000000000 -0500
+++ Python-3.14.5/Modules/pyexpat.c	2026-05-14 09:38:19.879682576 -0500
@@ -1416,7 +1416,10 @@ newxmlparseobject(pyexpat_state *state,
         Py_DECREF(self);
         return NULL;
     }
-#if XML_COMBINED_VERSION >= 20100
+#if XML_COMBINED_VERSION >= 20800
+    /* This feature was added upstream in libexpat 2.8.0. */
+    XML_SetHashSalt16Bytes(self->itself, _Py_HashSecret.expat.hashsalt16);
+#elif XML_COMBINED_VERSION >= 20100
     /* This feature was added upstream in libexpat 2.1.0. */
     XML_SetHashSalt(self->itself,
                     (unsigned long)_Py_HashSecret.expat.hashsalt);
@@ -2310,6 +2313,11 @@ pyexpat_exec(PyObject *mod)
 #else
     capi->SetHashSalt = NULL;
 #endif
+#if XML_COMBINED_VERSION >= 20800
+    capi->SetHashSalt16Bytes = XML_SetHashSalt16Bytes;
+#else
+    capi->SetHashSalt16Bytes = NULL;
+#endif
 #if XML_COMBINED_VERSION >= 20600
     capi->SetReparseDeferralEnabled = XML_SetReparseDeferralEnabled;
 #else
