Skip to content

Commit fd940db

Browse files
serhiy-storchakamiss-islington
authored andcommitted
[3.14] gh-119452: Fix a potential virtual memory allocation denial of service in http.server (GH-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. (cherry picked from commit 29c657a) Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
1 parent 1173f80 commit fd940db

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
@@ -128,6 +128,10 @@
128128

129129
DEFAULT_ERROR_CONTENT_TYPE = "text/html;charset=utf-8"
130130

131+
# Data larger than this will be read in chunks, to prevent extreme
132+
# overallocation.
133+
_MIN_READ_BUF_SIZE = 1 << 20
134+
131135
class HTTPServer(socketserver.TCPServer):
132136

133137
allow_reuse_address = 1 # Seems to make sense in testing environment
@@ -1215,7 +1219,16 @@ def run_cgi(self):
12151219
env = env
12161220
)
12171221
if self.command.lower() == "post" and nbytes > 0:
1218-
data = self.rfile.read(nbytes)
1222+
cursize = 0
1223+
data = self.rfile.read(min(nbytes, _MIN_READ_BUF_SIZE))
1224+
while (len(data) < nbytes and len(data) != cursize and
1225+
select.select([self.rfile._sock], [], [], 0)[0]):
1226+
cursize = len(data)
1227+
# This is a geometric increase in read size (never more
1228+
# than doubling our the current length of data per loop
1229+
# iteration).
1230+
delta = min(cursize, nbytes - cursize)
1231+
data += self.rfile.read(delta)
12191232
else:
12201233
data = None
12211234
# 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
@@ -667,6 +667,20 @@ def test_html_escape_filename(self):
667667
print("</pre>")
668668
"""
669669

670+
cgi_file7 = """\
671+
#!%s
672+
import os
673+
import sys
674+
675+
print("Content-type: text/plain")
676+
print()
677+
678+
content_length = int(os.environ["CONTENT_LENGTH"])
679+
body = sys.stdin.buffer.read(content_length)
680+
681+
print(f"{content_length} {len(body)}")
682+
"""
683+
670684

671685
@unittest.skipIf(hasattr(os, 'geteuid') and os.geteuid() == 0,
672686
"This test can't be run reliably as root (issue #13308).")
@@ -696,6 +710,8 @@ def setUp(self):
696710
self.file3_path = None
697711
self.file4_path = None
698712
self.file5_path = None
713+
self.file6_path = None
714+
self.file7_path = None
699715

700716
# The shebang line should be pure ASCII: use symlink if possible.
701717
# See issue #7668.
@@ -750,6 +766,11 @@ def setUp(self):
750766
file6.write(cgi_file6 % self.pythonexe)
751767
os.chmod(self.file6_path, 0o777)
752768

769+
self.file7_path = os.path.join(self.cgi_dir, 'file7.py')
770+
with open(self.file7_path, 'w', encoding='utf-8') as file7:
771+
file7.write(cgi_file7 % self.pythonexe)
772+
os.chmod(self.file7_path, 0o777)
773+
753774
os.chdir(self.parent_dir)
754775

755776
def tearDown(self):
@@ -771,6 +792,8 @@ def tearDown(self):
771792
os.remove(self.file5_path)
772793
if self.file6_path:
773794
os.remove(self.file6_path)
795+
if self.file7_path:
796+
os.remove(self.file7_path)
774797
os.rmdir(self.cgi_child_dir)
775798
os.rmdir(self.cgi_dir)
776799
os.rmdir(self.cgi_dir_in_sub_dir)
@@ -840,6 +863,21 @@ def test_post(self):
840863

841864
self.assertEqual(res.read(), b'1, python, 123456' + self.linesep)
842865

866+
def test_large_content_length(self):
867+
for w in range(15, 25):
868+
size = 1 << w
869+
body = b'X' * size
870+
headers = {'Content-Length' : str(size)}
871+
res = self.request('/cgi-bin/file7.py', 'POST', body, headers)
872+
self.assertEqual(res.read(), b'%d %d' % (size, size) + self.linesep)
873+
874+
def test_large_content_length_truncated(self):
875+
for w in range(18, 65):
876+
size = 1 << w
877+
headers = {'Content-Length' : str(size)}
878+
res = self.request('/cgi-bin/file1.py', 'POST', b'x', headers)
879+
self.assertEqual(res.read(), b'Hello World' + self.linesep)
880+
843881
def test_invaliduri(self):
844882
res = self.request('/cgi-bin/invalid')
845883
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)