Skip to content

Commit 29c657a

Browse files
[3.14] pythongh-119452: Fix a potential virtual memory allocation denial of service in http.server (pythonGH-119455)
The CGI server on Windows could consume the amount of memory specified in the Content-Length header of the request even if the client does not send such much data. Now it reads the POST request body by chunks, so that the memory consumption is proportional to the amount of sent data.
1 parent c4054f7 commit 29c657a

File tree

3 files changed

+57
-1
lines changed

3 files changed

+57
-1
lines changed

Lib/http/server.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@
134134

135135
DEFAULT_ERROR_CONTENT_TYPE = "text/html;charset=utf-8"
136136

137+
# Data larger than this will be read in chunks, to prevent extreme
138+
# overallocation.
139+
_MIN_READ_BUF_SIZE = 1 << 20
140+
137141
class HTTPServer(socketserver.TCPServer):
138142

139143
allow_reuse_address = True # Seems to make sense in testing environment
@@ -1284,7 +1288,16 @@ def run_cgi(self):
12841288
env = env
12851289
)
12861290
if self.command.lower() == "post" and nbytes > 0:
1287-
data = self.rfile.read(nbytes)
1291+
cursize = 0
1292+
data = self.rfile.read(min(nbytes, _MIN_READ_BUF_SIZE))
1293+
while (len(data) < nbytes and len(data) != cursize and
1294+
select.select([self.rfile._sock], [], [], 0)[0]):
1295+
cursize = len(data)
1296+
# This is a geometric increase in read size (never more
1297+
# than doubling our the current length of data per loop
1298+
# iteration).
1299+
delta = min(cursize, nbytes - cursize)
1300+
data += self.rfile.read(delta)
12881301
else:
12891302
data = None
12901303
# throw away additional data [see bug #427345]

Lib/test/test_httpservers.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,20 @@ def test_path_without_leading_slash(self):
913913
print("</pre>")
914914
"""
915915

916+
cgi_file7 = """\
917+
#!%s
918+
import os
919+
import sys
920+
921+
print("Content-type: text/plain")
922+
print()
923+
924+
content_length = int(os.environ["CONTENT_LENGTH"])
925+
body = sys.stdin.buffer.read(content_length)
926+
927+
print(f"{content_length} {len(body)}")
928+
"""
929+
916930

917931
@unittest.skipIf(hasattr(os, 'geteuid') and os.geteuid() == 0,
918932
"This test can't be run reliably as root (issue #13308).")
@@ -952,6 +966,8 @@ def setUp(self):
952966
self.file3_path = None
953967
self.file4_path = None
954968
self.file5_path = None
969+
self.file6_path = None
970+
self.file7_path = None
955971

956972
# The shebang line should be pure ASCII: use symlink if possible.
957973
# See issue #7668.
@@ -1006,6 +1022,11 @@ def setUp(self):
10061022
file6.write(cgi_file6 % self.pythonexe)
10071023
os.chmod(self.file6_path, 0o777)
10081024

1025+
self.file7_path = os.path.join(self.cgi_dir, 'file7.py')
1026+
with open(self.file7_path, 'w', encoding='utf-8') as file7:
1027+
file7.write(cgi_file7 % self.pythonexe)
1028+
os.chmod(self.file7_path, 0o777)
1029+
10091030
os.chdir(self.parent_dir)
10101031

10111032
def tearDown(self):
@@ -1028,6 +1049,8 @@ def tearDown(self):
10281049
os.remove(self.file5_path)
10291050
if self.file6_path:
10301051
os.remove(self.file6_path)
1052+
if self.file7_path:
1053+
os.remove(self.file7_path)
10311054
os.rmdir(self.cgi_child_dir)
10321055
os.rmdir(self.cgi_dir)
10331056
os.rmdir(self.cgi_dir_in_sub_dir)
@@ -1100,6 +1123,21 @@ def test_post(self):
11001123

11011124
self.assertEqual(res.read(), b'1, python, 123456' + self.linesep)
11021125

1126+
def test_large_content_length(self):
1127+
for w in range(15, 25):
1128+
size = 1 << w
1129+
body = b'X' * size
1130+
headers = {'Content-Length' : str(size)}
1131+
res = self.request('/cgi-bin/file7.py', 'POST', body, headers)
1132+
self.assertEqual(res.read(), b'%d %d' % (size, size) + self.linesep)
1133+
1134+
def test_large_content_length_truncated(self):
1135+
for w in range(18, 65):
1136+
size = 1 << w
1137+
headers = {'Content-Length' : str(size)}
1138+
res = self.request('/cgi-bin/file1.py', 'POST', b'x', headers)
1139+
self.assertEqual(res.read(), b'Hello World' + self.linesep)
1140+
11031141
def test_invaliduri(self):
11041142
res = self.request('/cgi-bin/invalid')
11051143
res.read()
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fix a potential memory denial of service in the :mod:`http.server` module.
2+
When a malicious user is connected to the CGI server on Windows, it could cause
3+
an arbitrary amount of memory to be allocated.
4+
This could have led to symptoms including a :exc:`MemoryError`, swapping, out
5+
of memory (OOM) killed processes or containers, or even system crashes.

0 commit comments

Comments
 (0)