Skip to content

Commit 7b24966

Browse files
authored
Merge branch '3.11' into backport-acfe02f-3.11
2 parents 7eca7c5 + 32eb314 commit 7b24966

18 files changed

Lines changed: 512 additions & 49 deletions

.readthedocs.yml

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,53 @@ sphinx:
88
configuration: Doc/conf.py
99

1010
build:
11-
os: ubuntu-22.04
11+
os: ubuntu-24.04
1212
tools:
1313
python: "3"
14+
apt_packages:
15+
- jq
1416

15-
commands:
16-
- make -C Doc venv html
17-
- mkdir _readthedocs
18-
- mv Doc/build/html _readthedocs/html
17+
jobs:
18+
post_system_dependencies:
19+
# https://docs.readthedocs.com/platform/stable/guides/build/skip-build.html#skip-builds-based-on-conditions
20+
#
21+
# Cancel building pull requests when there are no changes in the Doc
22+
# directory or RTD configuration, or if we can't cleanly merge the base
23+
# branch.
24+
- |
25+
set -eEux;
26+
if [ "$READTHEDOCS_VERSION_TYPE" = "external" ];
27+
then
28+
base_branch=$(wget -qO- "https://api.github.com/repos/python/cpython/pulls/$READTHEDOCS_VERSION" | jq -er ".base.ref");
29+
git fetch --depth=50 origin $base_branch:origin-$base_branch;
30+
for attempt in $(seq 10);
31+
do
32+
if ! git merge-base HEAD origin-$base_branch;
33+
then
34+
git fetch --deepen=50 origin $base_branch;
35+
else
36+
break;
37+
fi;
38+
done;
39+
if ! git -c "user.name=rtd" -c "user.email=no-reply@readthedocs.org" merge --no-stat --no-edit origin-$base_branch;
40+
then
41+
echo "Unsuccessful merge with '$base_branch' branch, skipping the build";
42+
exit 183;
43+
fi;
44+
if git diff --exit-code --stat origin-$base_branch -- Doc/ .readthedocs.yml;
45+
then
46+
echo "No changes to Doc/ - skipping the build.";
47+
exit 183;
48+
fi;
49+
fi;
50+
create_environment:
51+
- echo "Skipping default environment creation"
52+
install:
53+
- asdf plugin add uv
54+
- asdf install uv latest
55+
- asdf global uv latest
56+
build:
57+
html:
58+
- make -C Doc venv html
59+
- mkdir -p "$READTHEDOCS_OUTPUT"
60+
- mv Doc/build/html "$READTHEDOCS_OUTPUT/"

Doc/library/pyexpat.rst

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -239,16 +239,71 @@ XMLParser Objects
239239
.. versionadded:: 3.11.9
240240

241241

242-
:class:`!xmlparser` objects have the following methods to mitigate some
243-
common XML vulnerabilities.
242+
:class:`!xmlparser` objects have the following methods to tune protections
243+
against some common XML vulnerabilities.
244+
245+
.. method:: xmlparser.SetBillionLaughsAttackProtectionActivationThreshold(threshold, /)
246+
247+
Sets the number of output bytes needed to activate protection against
248+
`billion laughs`_ attacks.
249+
250+
The number of output bytes includes amplification from entity expansion
251+
and reading DTD files.
252+
253+
Parser objects usually have a protection activation threshold of 8 MiB,
254+
but the actual default value depends on the underlying Expat library.
255+
256+
An :exc:`ExpatError` is raised if this method is called on a
257+
|xml-non-root-parser| parser.
258+
The corresponding :attr:`~ExpatError.lineno` and :attr:`~ExpatError.offset`
259+
should not be used as they may have no special meaning.
260+
261+
.. note::
262+
263+
Activation thresholds below 4 MiB are known to break support for DITA 1.3
264+
payload and are hence not recommended.
265+
266+
.. versionadded:: next
267+
268+
.. method:: xmlparser.SetBillionLaughsAttackProtectionMaximumAmplification(max_factor, /)
269+
270+
Sets the maximum tolerated amplification factor for protection against
271+
`billion laughs`_ attacks.
272+
273+
The amplification factor is calculated as ``(direct + indirect) / direct``
274+
while parsing, where ``direct`` is the number of bytes read from
275+
the primary document in parsing and ``indirect`` is the number of
276+
bytes added by expanding entities and reading of external DTD files.
277+
278+
The *max_factor* value must be a non-NaN :class:`float` value greater than
279+
or equal to 1.0. Peak amplifications of factor 15,000 for the entire payload
280+
and of factor 30,000 in the middle of parsing have been observed with small
281+
benign files in practice. In particular, the activation threshold should be
282+
carefully chosen to avoid false positives.
283+
284+
Parser objects usually have a maximum amplification factor of 100,
285+
but the actual default value depends on the underlying Expat library.
286+
287+
An :exc:`ExpatError` is raised if this method is called on a
288+
|xml-non-root-parser| parser or if *max_factor* is outside the valid range.
289+
The corresponding :attr:`~ExpatError.lineno` and :attr:`~ExpatError.offset`
290+
should not be used as they may have no special meaning.
291+
292+
.. note::
293+
294+
The maximum amplification factor is only considered if the threshold
295+
that can be adjusted by :meth:`.SetBillionLaughsAttackProtectionActivationThreshold`
296+
is exceeded.
297+
298+
.. versionadded:: next
244299

245300
.. method:: xmlparser.SetAllocTrackerActivationThreshold(threshold, /)
246301

247302
Sets the number of allocated bytes of dynamic memory needed to activate
248303
protection against disproportionate use of RAM.
249304

250-
By default, parser objects have an allocation activation threshold of 64 MiB,
251-
or equivalently 67,108,864 bytes.
305+
Parser objects usually have an allocation activation threshold of 64 MiB,
306+
but the actual default value depends on the underlying Expat library.
252307

253308
An :exc:`ExpatError` is raised if this method is called on a
254309
|xml-non-root-parser| parser.
@@ -272,7 +327,8 @@ common XML vulnerabilities.
272327
near the start of parsing even with benign files in practice. In particular,
273328
the activation threshold should be carefully chosen to avoid false positives.
274329

275-
By default, parser objects have a maximum amplification factor of 100.0.
330+
Parser objects usually have a maximum amplification factor of 100,
331+
but the actual default value depends on the underlying Expat library.
276332

277333
An :exc:`ExpatError` is raised if this method is called on a
278334
|xml-non-root-parser| parser or if *max_factor* is outside the valid range.
@@ -1004,4 +1060,6 @@ The ``errors`` module has the following attributes:
10041060
not. See https://www.w3.org/TR/2006/REC-xml11-20060816/#NT-EncodingDecl
10051061
and https://www.iana.org/assignments/character-sets/character-sets.xhtml.
10061062
1063+
1064+
.. _billion laughs: https://en.wikipedia.org/wiki/Billion_laughs_attack
10071065
.. |xml-non-root-parser| replace:: :ref:`non-root <xmlparser-non-root>`

Doc/using/windows.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -401,9 +401,9 @@ on using nuget. What follows is a summary that is sufficient for Python
401401
developers.
402402

403403
The ``nuget.exe`` command line tool may be downloaded directly from
404-
``https://aka.ms/nugetclidl``, for example, using curl or PowerShell. With the
405-
tool, the latest version of Python for 64-bit or 32-bit machines is installed
406-
using::
404+
``https://dist.nuget.org/win-x86-commandline/latest/nuget.exe``, for example,
405+
using curl or PowerShell. With the tool, the latest version of Python for
406+
64-bit or 32-bit machines is installed using::
407407

408408
nuget.exe install python -ExcludeVersion -OutputDirectory .
409409
nuget.exe install pythonx86 -ExcludeVersion -OutputDirectory .

Include/pyexpat.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ struct PyExpat_CAPI
5757
XML_Parser parser, unsigned long long activationThresholdBytes);
5858
XML_Bool (*SetAllocTrackerMaximumAmplification)(
5959
XML_Parser parser, float maxAmplificationFactor);
60+
/* might be NULL for expat < 2.4.0 */
61+
XML_Bool (*SetBillionLaughsAttackProtectionActivationThreshold)(
62+
XML_Parser parser, unsigned long long activationThresholdBytes);
63+
XML_Bool (*SetBillionLaughsAttackProtectionMaximumAmplification)(
64+
XML_Parser parser, float maxAmplificationFactor);
6065
/* always add new stuff to the end! */
6166
};
6267

Lib/shutil.py

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1241,27 +1241,9 @@ def _unpack_zipfile(filename, extract_dir):
12411241
if not zipfile.is_zipfile(filename):
12421242
raise ReadError("%s is not a zip file" % filename)
12431243

1244-
zip = zipfile.ZipFile(filename)
1245-
try:
1246-
for info in zip.infolist():
1247-
name = info.filename
1248-
1249-
# don't extract absolute paths or ones with .. in them
1250-
if name.startswith('/') or '..' in name:
1251-
continue
1252-
1253-
targetpath = os.path.join(extract_dir, *name.split('/'))
1254-
if not targetpath:
1255-
continue
1256-
1257-
_ensure_directory(targetpath)
1258-
if not name.endswith('/'):
1259-
# file
1260-
with zip.open(name, 'r') as source, \
1261-
open(targetpath, 'wb') as target:
1262-
copyfileobj(source, target)
1263-
finally:
1264-
zip.close()
1244+
with zipfile.ZipFile(filename) as zip:
1245+
zip._ignore_invalid_names = True
1246+
zip.extractall(extract_dir)
12651247

12661248
def _unpack_tarfile(filename, extract_dir, *, filter=None):
12671249
"""Unpack tar/tar.gz/tar.bz2/tar.xz `filename` to `extract_dir`

Lib/test/test_asyncio/test_sock_lowlevel.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,27 @@ def test_recvfrom_into(self):
427427
self.loop.run_until_complete(
428428
self._basetest_datagram_recvfrom_into(server_address))
429429

430+
async def _basetest_datagram_recvfrom_into_wrong_size(self, server_address):
431+
# Call sock_sendto() with a size larger than the buffer
432+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
433+
sock.setblocking(False)
434+
435+
buf = bytearray(5000)
436+
data = b'\x01' * 4096
437+
wrong_size = len(buf) + 1
438+
await self.loop.sock_sendto(sock, data, server_address)
439+
with self.assertRaises(ValueError):
440+
await self.loop.sock_recvfrom_into(
441+
sock, buf, wrong_size)
442+
443+
size, addr = await self.loop.sock_recvfrom_into(sock, buf)
444+
self.assertEqual(buf[:size], data)
445+
446+
def test_recvfrom_into_wrong_size(self):
447+
with test_utils.run_udp_echo_server() as server_address:
448+
self.loop.run_until_complete(
449+
self._basetest_datagram_recvfrom_into_wrong_size(server_address))
450+
430451
async def _basetest_datagram_sendto_blocking(self, server_address):
431452
# Sad path, sock.sendto() raises BlockingIOError
432453
# This involves patching sock.sendto() to raise BlockingIOError but

Lib/test/test_pyexpat.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,64 @@ def test_set_maximum_amplification__fail_for_subparser(self):
10031003
self.assert_root_parser_failure(setter, 123.45)
10041004

10051005

1006+
@unittest.skipIf(expat.version_info < (2, 4, 0), "requires Expat >= 2.4.0")
1007+
class ExpansionProtectionTest(AttackProtectionTestBase, unittest.TestCase):
1008+
1009+
def assert_rejected(self, func, /, *args, **kwargs):
1010+
"""Check that func(*args, **kwargs) hits the allocation limit."""
1011+
msg = (
1012+
r"limit on input amplification factor \(from DTD and entities\) "
1013+
r"breached: line \d+, column \d+"
1014+
)
1015+
self.assertRaisesRegex(expat.ExpatError, msg, func, *args, **kwargs)
1016+
1017+
def set_activation_threshold(self, parser, threshold):
1018+
return parser.SetBillionLaughsAttackProtectionActivationThreshold(threshold)
1019+
1020+
def set_maximum_amplification(self, parser, max_factor):
1021+
return parser.SetBillionLaughsAttackProtectionMaximumAmplification(max_factor)
1022+
1023+
def test_set_activation_threshold__threshold_reached(self):
1024+
parser = expat.ParserCreate()
1025+
# Choose a threshold expected to be always reached.
1026+
self.set_activation_threshold(parser, 3)
1027+
# Check that the threshold is reached by choosing a small factor
1028+
# and a payload whose peak amplification factor exceeds it.
1029+
self.assertIsNone(self.set_maximum_amplification(parser, 1.0))
1030+
payload = self.exponential_expansion_payload(ncols=10, nrows=4)
1031+
self.assert_rejected(parser.Parse, payload, True)
1032+
1033+
def test_set_activation_threshold__threshold_not_reached(self):
1034+
parser = expat.ParserCreate()
1035+
# Choose a threshold expected to be never reached.
1036+
self.set_activation_threshold(parser, pow(10, 5))
1037+
# Check that the threshold is reached by choosing a small factor
1038+
# and a payload whose peak amplification factor exceeds it.
1039+
self.assertIsNone(self.set_maximum_amplification(parser, 1.0))
1040+
payload = self.exponential_expansion_payload(ncols=10, nrows=4)
1041+
self.assertIsNotNone(parser.Parse(payload, True))
1042+
1043+
def test_set_maximum_amplification__amplification_exceeded(self):
1044+
parser = expat.ParserCreate()
1045+
# Unconditionally enable maximum activation factor.
1046+
self.set_activation_threshold(parser, 0)
1047+
# Choose a max amplification factor expected to always be exceeded.
1048+
self.assertIsNone(self.set_maximum_amplification(parser, 1.0))
1049+
# Craft a payload for which the peak amplification factor is > 1.0.
1050+
payload = self.exponential_expansion_payload(ncols=1, nrows=2)
1051+
self.assert_rejected(parser.Parse, payload, True)
1052+
1053+
def test_set_maximum_amplification__amplification_not_exceeded(self):
1054+
parser = expat.ParserCreate()
1055+
# Unconditionally enable maximum activation factor.
1056+
self.set_activation_threshold(parser, 0)
1057+
# Choose a max amplification factor expected to never be exceeded.
1058+
self.assertIsNone(self.set_maximum_amplification(parser, 1e4))
1059+
# Craft a payload for which the peak amplification factor is < 1e4.
1060+
payload = self.exponential_expansion_payload(ncols=1, nrows=2)
1061+
self.assertIsNotNone(parser.Parse(payload, True))
1062+
1063+
10061064
@unittest.skipIf(expat.version_info < (2, 7, 2), "requires Expat >= 2.7.2")
10071065
class MemoryProtectionTest(AttackProtectionTestBase, unittest.TestCase):
10081066

Lib/test/test_shutil.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1799,6 +1799,71 @@ def test_unpack_archive_zip(self):
17991799
with self.assertRaises(TypeError):
18001800
self.check_unpack_archive('zip', filter='data')
18011801

1802+
def test_unpack_archive_zip_badpaths(self):
1803+
srcdir = self.mkdtemp()
1804+
zipname = os.path.join(srcdir, 'test.zip')
1805+
abspath = os.path.join(srcdir, 'abspath')
1806+
with zipfile.ZipFile(zipname, 'w') as zf:
1807+
zf.writestr(abspath, 'badfile')
1808+
zf.writestr(os.sep + abspath, 'badfile')
1809+
zf.writestr('/abspath', 'badfile')
1810+
zf.writestr('C:/abspath', 'badfile')
1811+
zf.writestr('D:\\abspath', 'badfile')
1812+
zf.writestr('E:abspath', 'badfile')
1813+
zf.writestr('F:/G:/abspath', 'badfile')
1814+
zf.writestr('//server/share/abspath', 'badfile')
1815+
zf.writestr('\\\\server2\\share\\abspath', 'badfile')
1816+
zf.writestr('../relpath', 'badfile')
1817+
zf.writestr(os.pardir + os.sep + 'relpath2', 'badfile')
1818+
zf.writestr('good/file', 'goodfile')
1819+
zf.writestr('good..file', 'goodfile')
1820+
1821+
dstdir = os.path.join(self.mkdtemp(), 'dst')
1822+
unpack_archive(zipname, dstdir)
1823+
self.assertTrue(os.path.isfile(os.path.join(dstdir, 'good', 'file')))
1824+
self.assertTrue(os.path.isfile(os.path.join(dstdir, 'good..file')))
1825+
self.assertFalse(os.path.exists(abspath))
1826+
self.assertFalse(os.path.exists(os.path.join(dstdir, 'abspath')))
1827+
self.assertFalse(os.path.exists(os.path.join(dstdir, 'G_')))
1828+
self.assertFalse(os.path.exists(os.path.join(dstdir, 'server')))
1829+
if os.name != 'nt':
1830+
self.assertTrue(os.path.isfile(os.path.join(dstdir, 'C:', 'abspath')))
1831+
self.assertTrue(os.path.isfile(os.path.join(dstdir, 'D:\\abspath')))
1832+
self.assertTrue(os.path.isfile(os.path.join(dstdir, 'E:abspath')))
1833+
self.assertTrue(os.path.isfile(os.path.join(dstdir, 'F:', 'G:', 'abspath')))
1834+
self.assertTrue(os.path.isfile(os.path.join(dstdir, '\\\\server2\\share\\abspath')))
1835+
if os.pardir == '..':
1836+
self.assertFalse(os.path.exists(os.path.join(dstdir, '..', 'relpath')))
1837+
self.assertFalse(os.path.exists(os.path.join(dstdir, 'relpath')))
1838+
else:
1839+
self.assertTrue(os.path.isfile(os.path.join(dstdir, '..', 'relpath')))
1840+
self.assertFalse(os.path.exists(os.path.join(dstdir, os.pardir, 'relpath2')))
1841+
self.assertFalse(os.path.exists(os.path.join(dstdir, 'relpath2')))
1842+
1843+
dstdir2 = os.path.join(self.mkdtemp(), 'dst')
1844+
os.mkdir(dstdir2)
1845+
with os_helper.change_cwd(dstdir2):
1846+
unpack_archive(zipname, '')
1847+
self.assertTrue(os.path.isfile(os.path.join('good', 'file')))
1848+
self.assertTrue(os.path.isfile('good..file'))
1849+
self.assertFalse(os.path.exists(abspath))
1850+
self.assertFalse(os.path.exists('abspath'))
1851+
self.assertFalse(os.path.exists('C_'))
1852+
self.assertFalse(os.path.exists('server'))
1853+
if os.name != 'nt':
1854+
self.assertTrue(os.path.isfile(os.path.join('C:', 'abspath')))
1855+
self.assertTrue(os.path.isfile('D:\\abspath'))
1856+
self.assertTrue(os.path.isfile('E:abspath'))
1857+
self.assertTrue(os.path.isfile(os.path.join('F:', 'G:', 'abspath')))
1858+
self.assertTrue(os.path.isfile('\\\\server2\\share\\abspath'))
1859+
if os.pardir == '..':
1860+
self.assertFalse(os.path.exists(os.path.join('..', 'relpath')))
1861+
self.assertFalse(os.path.exists('relpath'))
1862+
else:
1863+
self.assertTrue(os.path.isfile(os.path.join('..', 'relpath')))
1864+
self.assertFalse(os.path.exists(os.path.join(os.pardir, 'relpath2')))
1865+
self.assertFalse(os.path.exists('relpath2'))
1866+
18021867
def test_unpack_registry(self):
18031868

18041869
formats = get_unpack_formats()

0 commit comments

Comments
 (0)