diff --git a/Lib/ftplib.py b/Lib/ftplib.py
index 640acc64f620cc..2f092d50f31782 100644
--- a/Lib/ftplib.py
+++ b/Lib/ftplib.py
@@ -883,7 +883,16 @@ def ftpcp(source, sourcename, target, targetname = '', type = 'I'):
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 --git a/Lib/test/test_ftplib.py b/Lib/test/test_ftplib.py
index c864d401f9ed67..f1eff9430f7351 100644
--- a/Lib/test/test_ftplib.py
+++ b/Lib/test/test_ftplib.py
@@ -16,7 +16,7 @@
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 @@ def testTimeoutDirectAccess(self):
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 --git a/Misc/NEWS.d/next/Security/2026-05-10-18-05-32.gh-issue-87451.XkKB6M.rst b/Misc/NEWS.d/next/Security/2026-05-10-18-05-32.gh-issue-87451.XkKB6M.rst
new file mode 100644
index 00000000000000..21a79c3e0e7db7
--- /dev/null
+++ b/Misc/NEWS.d/next/Security/2026-05-10-18-05-32.gh-issue-87451.XkKB6M.rst
@@ -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 --git a/Misc/NEWS.d/next/Security/2026-05-11-21-15-07.gh-issue-149698.OudOcW.rst b/Misc/NEWS.d/next/Security/2026-05-11-21-15-07.gh-issue-149698.OudOcW.rst
new file mode 100644
index 00000000000000..3c8671b9a5adc4
--- /dev/null
+++ b/Misc/NEWS.d/next/Security/2026-05-11-21-15-07.gh-issue-149698.OudOcW.rst
@@ -0,0 +1,2 @@
+Update bundled `libexpat `_ to version 2.8.1
+for the fix for :cve:`2026-45186`.
diff --git a/Misc/sbom.spdx.json b/Misc/sbom.spdx.json
index 881b55564cd53b..28a8b59f17cf63 100644
--- a/Misc/sbom.spdx.json
+++ b/Misc/sbom.spdx.json
@@ -48,11 +48,11 @@
"checksums": [
{
"algorithm": "SHA1",
- "checksumValue": "5343adc95840915b022b1d4524d0acb66b369ba2"
+ "checksumValue": "58101ef0951568acadd3117033bef084fea24cc1"
},
{
"algorithm": "SHA256",
- "checksumValue": "1ec3bad08b6864c2c479e1fd941038c2dcd24c6d9a16400f4da54912d95aa321"
+ "checksumValue": "52d756026bf09befdb211c453e2009a646d6c6b519e6885e971b2550396619fb"
}
],
"fileName": "Modules/expat/expat.h"
@@ -174,11 +174,11 @@
"checksums": [
{
"algorithm": "SHA1",
- "checksumValue": "cb0af01558ec7b6474d2bd0c9386380c82618e8f"
+ "checksumValue": "1dad2ab196cdbe37572674c465bd9187fdbe4495"
},
{
"algorithm": "SHA256",
- "checksumValue": "6745a6b8cdd7344d4bd8f27f605363ed746e57ff02d4ebce3eb1806579cd030f"
+ "checksumValue": "740137e670d2f3b7269364ffb6f60064e6560091850c5d6f2c3bb1b8ca6e3dd1"
}
],
"fileName": "Modules/expat/xmlparse.c"
@@ -1002,14 +1002,14 @@
"checksums": [
{
"algorithm": "SHA256",
- "checksumValue": "c7cec5f60ea3a42e7780781c6745255c19aa3dbfeeae58646b7132f88dc24780"
+ "checksumValue": "a52eb72108be160e190b5cafa5bba8663f1313f2013e26060d1c18e26e31067b"
}
],
- "downloadLocation": "https://github.com/libexpat/libexpat/releases/download/R_2_8_0/expat-2.8.0.tar.gz",
+ "downloadLocation": "https://github.com/libexpat/libexpat/releases/download/R_2_8_1/expat-2.8.1.tar.gz",
"externalRefs": [
{
"referenceCategory": "SECURITY",
- "referenceLocator": "cpe:2.3:a:libexpat_project:libexpat:2.8.0:*:*:*:*:*:*:*",
+ "referenceLocator": "cpe:2.3:a:libexpat_project:libexpat:2.8.1:*:*:*:*:*:*:*",
"referenceType": "cpe23Type"
}
],
@@ -1017,7 +1017,7 @@
"name": "expat",
"originator": "Organization: Expat development team",
"primaryPackagePurpose": "SOURCE",
- "versionInfo": "2.8.0"
+ "versionInfo": "2.8.1"
},
{
"SPDXID": "SPDXRef-PACKAGE-hacl-star",
diff --git a/Modules/_posixsubprocess.c b/Modules/_posixsubprocess.c
index b7f39ea3d499e4..9867f077faef7a 100644
--- a/Modules/_posixsubprocess.c
+++ b/Modules/_posixsubprocess.c
@@ -63,7 +63,7 @@
# endif
#endif
-#if defined(__FreeBSD__) || (defined(__APPLE__) && defined(__MACH__)) || defined(__DragonFly__)
+#if defined(__CYGWIN__) || defined(__FreeBSD__) || (defined(__APPLE__) && defined(__MACH__)) || defined(__DragonFly__)
# define FD_DIR "/dev/fd"
#else
# define FD_DIR "/proc/self/fd"
diff --git a/Modules/expat/expat.h b/Modules/expat/expat.h
index 79c609f19aa4cf..ec3f58544cb00d 100644
--- a/Modules/expat/expat.h
+++ b/Modules/expat/expat.h
@@ -1094,7 +1094,7 @@ XML_SetReparseDeferralEnabled(XML_Parser parser, XML_Bool enabled);
*/
# define XML_MAJOR_VERSION 2
# define XML_MINOR_VERSION 8
-# define XML_MICRO_VERSION 0
+# define XML_MICRO_VERSION 1
# ifdef __cplusplus
}
diff --git a/Modules/expat/refresh.sh b/Modules/expat/refresh.sh
index 774e0b89d94c0e..fa3692f9379510 100755
--- a/Modules/expat/refresh.sh
+++ b/Modules/expat/refresh.sh
@@ -12,9 +12,9 @@ fi
# Update this when updating to a new version after verifying that the changes
# the update brings in are good. These values are used for verifying the SBOM, too.
-expected_libexpat_tag="R_2_8_0"
-expected_libexpat_version="2.8.0"
-expected_libexpat_sha256="c7cec5f60ea3a42e7780781c6745255c19aa3dbfeeae58646b7132f88dc24780"
+expected_libexpat_tag="R_2_8_1"
+expected_libexpat_version="2.8.1"
+expected_libexpat_sha256="a52eb72108be160e190b5cafa5bba8663f1313f2013e26060d1c18e26e31067b"
expat_dir="$(realpath "$(dirname -- "${BASH_SOURCE[0]}")")"
cd ${expat_dir}
diff --git a/Modules/expat/xmlparse.c b/Modules/expat/xmlparse.c
index e6842f3f0bf750..95d346758563ab 100644
--- a/Modules/expat/xmlparse.c
+++ b/Modules/expat/xmlparse.c
@@ -1,4 +1,4 @@
-/* a5d18f6a50f536615ac1c70304f87d94f99cc85a86b502188952440610ccf0f8 (2.8.0+)
+/* 75ef4224f81c052e9e5aeea2ac7de75357d2169ff9908e39edc08b9dc3052513 (2.8.1+)
__ __ _
___\ \/ /_ __ __ _| |_
/ _ \\ /| '_ \ / _` | __|
@@ -387,6 +387,7 @@ typedef struct {
int nDefaultAtts;
int allocDefaultAtts;
DEFAULT_ATTRIBUTE *defaultAtts;
+ HASH_TABLE defaultAttsNames;
} ELEMENT_TYPE;
typedef struct {
@@ -3769,6 +3770,8 @@ storeAtts(XML_Parser parser, const ENCODING *enc, const char *attStr,
sizeof(ELEMENT_TYPE));
if (! elementType)
return XML_ERROR_NO_MEMORY;
+ if (! elementType->defaultAttsNames.parser)
+ hashTableInit(&(elementType->defaultAttsNames), parser);
if (parser->m_ns && ! setElementTypePrefix(parser, elementType))
return XML_ERROR_NO_MEMORY;
}
@@ -7102,10 +7105,10 @@ defineAttribute(ELEMENT_TYPE *type, ATTRIBUTE_ID *attId, XML_Bool isCdata,
if (value || isId) {
/* The handling of default attributes gets messed up if we have
a default which duplicates a non-default. */
- int i;
- for (i = 0; i < type->nDefaultAtts; i++)
- if (attId == type->defaultAtts[i].id)
- return 1;
+ NAMED *const nameFound
+ = (NAMED *)lookup(parser, &(type->defaultAttsNames), attId->name, 0);
+ if (nameFound)
+ return 1;
if (isId && ! type->idAtt && ! attId->xmlns)
type->idAtt = attId;
}
@@ -7152,6 +7155,12 @@ defineAttribute(ELEMENT_TYPE *type, ATTRIBUTE_ID *attId, XML_Bool isCdata,
att->isCdata = isCdata;
if (! isCdata)
attId->maybeTokenized = XML_TRUE;
+
+ NAMED *const nameAddedOrFound = (NAMED *)lookup(
+ parser, &(type->defaultAttsNames), attId->name, sizeof(NAMED));
+ if (! nameAddedOrFound)
+ return 0;
+
type->nDefaultAtts += 1;
return 1;
}
@@ -7477,6 +7486,7 @@ dtdReset(DTD *p, XML_Parser parser) {
ELEMENT_TYPE *e = (ELEMENT_TYPE *)hashTableIterNext(&iter);
if (! e)
break;
+ hashTableDestroy(&(e->defaultAttsNames));
if (e->allocDefaultAtts != 0)
FREE(parser, e->defaultAtts);
}
@@ -7518,6 +7528,7 @@ dtdDestroy(DTD *p, XML_Bool isDocEntity, XML_Parser parser) {
ELEMENT_TYPE *e = (ELEMENT_TYPE *)hashTableIterNext(&iter);
if (! e)
break;
+ hashTableDestroy(&(e->defaultAttsNames));
if (e->allocDefaultAtts != 0)
FREE(parser, e->defaultAtts);
}
@@ -7611,6 +7622,10 @@ dtdCopy(XML_Parser oldParser, DTD *newDtd, const DTD *oldDtd,
sizeof(ELEMENT_TYPE));
if (! newE)
return 0;
+
+ if (! newE->defaultAttsNames.parser)
+ hashTableInit(&(newE->defaultAttsNames), parser);
+
if (oldE->nDefaultAtts) {
/* Detect and prevent integer overflow.
* The preprocessor guard addresses the "always false" warning
@@ -7635,8 +7650,9 @@ dtdCopy(XML_Parser oldParser, DTD *newDtd, const DTD *oldDtd,
newE->prefix = (PREFIX *)lookup(oldParser, &(newDtd->prefixes),
oldE->prefix->name, 0);
for (i = 0; i < newE->nDefaultAtts; i++) {
+ const XML_Char *const attributeName = oldE->defaultAtts[i].id->name;
newE->defaultAtts[i].id = (ATTRIBUTE_ID *)lookup(
- oldParser, &(newDtd->attributeIds), oldE->defaultAtts[i].id->name, 0);
+ oldParser, &(newDtd->attributeIds), attributeName, 0);
newE->defaultAtts[i].isCdata = oldE->defaultAtts[i].isCdata;
if (oldE->defaultAtts[i].value) {
newE->defaultAtts[i].value
@@ -7645,6 +7661,12 @@ dtdCopy(XML_Parser oldParser, DTD *newDtd, const DTD *oldDtd,
return 0;
} else
newE->defaultAtts[i].value = NULL;
+
+ NAMED *const nameAddedOrFound = (NAMED *)lookup(
+ parser, &(newE->defaultAttsNames), attributeName, sizeof(NAMED));
+ if (! nameAddedOrFound) {
+ return 0;
+ }
}
}
@@ -8391,6 +8413,8 @@ getElementType(XML_Parser parser, const ENCODING *enc, const char *ptr,
sizeof(ELEMENT_TYPE));
if (! ret)
return NULL;
+ if (! ret->defaultAttsNames.parser)
+ hashTableInit(&(ret->defaultAttsNames), getRootParserOf(parser, NULL));
if (ret->name != name)
poolDiscard(&dtd->pool);
else {
diff --git a/Objects/mimalloc/prim/unix/prim.c b/Objects/mimalloc/prim/unix/prim.c
index 1598ebabf0a4da..6f78eb72789e8e 100644
--- a/Objects/mimalloc/prim/unix/prim.c
+++ b/Objects/mimalloc/prim/unix/prim.c
@@ -751,7 +751,7 @@ bool _mi_prim_random_buf(void* buf, size_t buf_len) {
#elif defined(__ANDROID__) || defined(__DragonFly__) || \
defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__) || \
- defined(__sun)
+ defined(__sun) || defined(__CYGWIN__)
#include
bool _mi_prim_random_buf(void* buf, size_t buf_len) {
diff --git a/Objects/typeobject.c b/Objects/typeobject.c
index 9a18ca72516da7..7cca137f74be58 100644
--- a/Objects/typeobject.c
+++ b/Objects/typeobject.c
@@ -4841,6 +4841,18 @@ type_new_set_attrs(const type_new_ctx *ctx, PyTypeObject *type)
if (type_new_set_classdictcell(dict) < 0) {
return -1;
}
+
+#ifdef Py_GIL_DISABLED
+ // enable deferred reference counting on functions and descriptors
+ Py_ssize_t pos = 0;
+ PyObject *key, *value;
+ while (PyDict_Next(dict, &pos, &key, &value)) {
+ if (PyFunction_Check(value) || Py_TYPE(value)->tp_descr_get != NULL) {
+ PyUnstable_Object_EnableDeferredRefcount(value);
+ }
+ }
+#endif
+
return 0;
}
@@ -6746,12 +6758,11 @@ type_setattro(PyObject *self, PyObject *name, PyObject *value)
assert(!_PyType_HasFeature(metatype, Py_TPFLAGS_MANAGED_DICT));
#ifdef Py_GIL_DISABLED
- // gh-139103: Enable deferred refcounting for functions assigned
- // to type objects. This is important for `dataclass.__init__`,
- // which is generated dynamically.
- if (value != NULL &&
- PyFunction_Check(value) &&
- !_PyObject_HasDeferredRefcount(value))
+ // gh-139103: Enable deferred refcounting for functions and descriptors
+ // assigned to type objects. This is important for `dataclass.__init__`,
+ // which is generated dynamically, and for descriptor scaling on
+ // free-threaded builds.
+ if (value != NULL && (PyFunction_Check(value) || Py_TYPE(value)->tp_descr_get != NULL))
{
PyUnstable_Object_EnableDeferredRefcount(value);
}
@@ -11089,10 +11100,12 @@ static PyObject *
slot_tp_descr_get(PyObject *self, PyObject *obj, PyObject *type)
{
PyTypeObject *tp = Py_TYPE(self);
- PyObject *get;
-
- get = _PyType_LookupRef(tp, &_Py_ID(__get__));
- if (get == NULL) {
+ PyThreadState *tstate = _PyThreadState_GET();
+ _PyCStackRef cref;
+ _PyThreadState_PushCStackRef(tstate, &cref);
+ _PyType_LookupStackRefAndVersion(tp, &_Py_ID(__get__), &cref.ref);
+ if (PyStackRef_IsNull(cref.ref)) {
+ _PyThreadState_PopCStackRef(tstate, &cref);
#ifndef Py_GIL_DISABLED
/* Avoid further slowdowns */
if (tp->tp_descr_get == slot_tp_descr_get)
@@ -11104,9 +11117,10 @@ slot_tp_descr_get(PyObject *self, PyObject *obj, PyObject *type)
obj = Py_None;
if (type == NULL)
type = Py_None;
+ PyObject *get = PyStackRef_AsPyObjectBorrow(cref.ref);
PyObject *stack[3] = {self, obj, type};
PyObject *res = PyObject_Vectorcall(get, stack, 3, NULL);
- Py_DECREF(get);
+ _PyThreadState_PopCStackRef(tstate, &cref);
return res;
}
diff --git a/Python/dynload_shlib.c b/Python/dynload_shlib.c
index 2e1455fbe232f4..0ff88ad330fd09 100644
--- a/Python/dynload_shlib.c
+++ b/Python/dynload_shlib.c
@@ -38,6 +38,7 @@
const char *_PyImport_DynLoadFiletab[] = {
#ifdef __CYGWIN__
+ "." SOABI ".dll",
".dll",
#else /* !__CYGWIN__ */
"." SOABI ".so",
diff --git a/Tools/ftscalingbench/ftscalingbench.py b/Tools/ftscalingbench/ftscalingbench.py
index 60f43b99c0f69d..c8a914c22a9e13 100644
--- a/Tools/ftscalingbench/ftscalingbench.py
+++ b/Tools/ftscalingbench/ftscalingbench.py
@@ -279,6 +279,23 @@ def staticmethod_call():
for _ in range(1000 * WORK_SCALE):
obj.my_staticmethod()
+
+class MyDescriptor:
+ def __get__(self, obj, objtype=None):
+ return 42
+
+ def __set__(self, obj, value):
+ pass
+
+class MyClassWithDescriptor:
+ attr = MyDescriptor()
+
+@register_benchmark
+def descriptor():
+ obj = MyClassWithDescriptor()
+ for _ in range(1000 * WORK_SCALE):
+ obj.attr
+
@register_benchmark
def deepcopy():
x = {'list': [1, 2], 'tuple': (1, None)}