Skip to content

Commit 0e4f4f1

Browse files
[3.14] gh-119452: Fix a potential virtual memory allocation denial of service in http.server (GH-142216)
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, therefore the memory consumption is proportional to the amount of sent data.
1 parent f130b06 commit 0e4f4f1

File tree

3 files changed

+60
-1
lines changed

3 files changed

+60
-1
lines changed

Lib/http/server.py

Lines changed: 16 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,18 @@ 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:
1294+
cursize = len(data)
1295+
# This is a geometric increase in read size (never more
1296+
# than doubling out the current length of data per loop
1297+
# iteration).
1298+
delta = min(cursize, nbytes - cursize)
1299+
try:
1300+
data += self.rfile.read(delta)
1301+
except TimeoutError:
1302+
break
12881303
else:
12891304
data = None
12901305
# throw away additional data [see bug #427345]

Lib/test/test_httpservers.py

Lines changed: 39 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,22 @@ 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+
with support.swap_attr(self.request_handler, 'timeout', 0.001):
1136+
for w in range(18, 65):
1137+
size = 1 << w
1138+
headers = {'Content-Length' : str(size)}
1139+
res = self.request('/cgi-bin/file1.py', 'POST', b'x', headers)
1140+
self.assertEqual(res.read(), b'Hello World' + self.linesep)
1141+
11031142
def test_invaliduri(self):
11041143
res = self.request('/cgi-bin/invalid')
11051144
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)