Skip to content

Commit 5a2795c

Browse files
serhiy-storchakamcepl
authored andcommitted
[CVE-2025-13836] Fix a potential denial of service in http.client
Reading the whole body of the HTTP response could cause OOM if the Content-Length value is too large even if the server does not send a large amount of data. Now the HTTP client reads large data by chunks, therefore the amount of consumed memory is proportional to the amount of sent data. Fixes: bsc#1254400 (CVE-2025-13836) Fixes: gh#python#119451 From-PR: gh#python/cpython!119454 Patch: CVE-2025-13836-http-resp-cont-len.patch
1 parent 9f1173f commit 5a2795c

4 files changed

Lines changed: 159 additions & 8 deletions

File tree

‎Lib/http/client.py‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@
113113
_MAXLINE = 65536
114114
_MAXHEADERS = 100
115115

116+
# Data larger than this will be read in chunks, to prevent extreme
117+
# overallocation.
118+
_MIN_READ_BUF_SIZE = 1 << 20
119+
120+
116121
# Header name/value ABNF (http://tools.ietf.org/html/rfc7230#section-3.2)
117122
#
118123
# VCHAR = %x21-7E
@@ -625,14 +630,25 @@ def _safe_read(self, amt):
625630
reading. If the bytes are truly not available (due to EOF), then the
626631
IncompleteRead exception can be used to detect the problem.
627632
"""
628-
s = []
629-
while amt > 0:
630-
chunk = self.fp.read(min(amt, MAXAMOUNT))
631-
if not chunk:
632-
raise IncompleteRead(b''.join(s), amt)
633-
s.append(chunk)
634-
amt -= len(chunk)
635-
return b"".join(s)
633+
cursize = min(amt, _MIN_READ_BUF_SIZE)
634+
data = self.fp.read(cursize)
635+
if len(data) >= amt:
636+
return data
637+
if len(data) < cursize:
638+
raise IncompleteRead(data, amt - len(data))
639+
640+
data = io.BytesIO(data)
641+
data.seek(0, 2)
642+
while True:
643+
# This is a geometric increase in read size (never more than
644+
# doubling out the current length of data per loop iteration).
645+
delta = min(cursize, amt - cursize)
646+
data.write(self.fp.read(delta))
647+
if data.tell() >= amt:
648+
return data.getvalue()
649+
cursize += delta
650+
if data.tell() < cursize:
651+
raise IncompleteRead(data.getvalue(), amt - data.tell())
636652

637653
def _safe_readinto(self, b):
638654
"""Same as _safe_read, but for reading into a buffer."""

‎Lib/socket.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
socket.setdefaulttimeout() -- set the default timeout value
2727
create_connection() -- connects to an address, with an optional timeout and
2828
optional source address.
29+
create_server() -- create a TCP socket and bind it to a specified address.
2930
3031
[*] not available on all platforms!
3132
@@ -748,3 +749,64 @@ def getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
748749
_intenum_converter(socktype, SocketKind),
749750
proto, canonname, sa))
750751
return addrlist
752+
753+
754+
def create_server(address, family=None, backlog=None, reuse_port=False):
755+
"""Convenience function which creates a SOCK_STREAM type socket
756+
bound to *address* (a 2-tuple (host, port)) and return the socket
757+
object.
758+
759+
*family* should be either AF_INET or AF_INET6.
760+
*backlog* is the queue size passed to socket.listen().
761+
*reuse_port* dictates whether to use the SO_REUSEPORT socket option.
762+
*dualstack_ipv6*: if true and the platform supports it, it will
763+
create an AF_INET6 socket able to accept both IPv4 or IPv6
764+
connections. When false it will explicitly disable this option on
765+
platforms that enable it by default (e.g. Linux).
766+
767+
>>> with create_server(('', 8000)) as server:
768+
... while True:
769+
... conn, addr = server.accept()
770+
... # handle new connection
771+
"""
772+
if family is None:
773+
family = getattr(socket, "AF_UNSPEC", 0)
774+
775+
host, port = address
776+
err = None
777+
778+
# AI_PASSIVE tells getaddrinfo we intend to bind() to this address
779+
try:
780+
addr_infos = getaddrinfo(
781+
host, port, family, SOCK_STREAM, 0, AI_PASSIVE
782+
)
783+
except gaierror as e:
784+
raise OSError(f"getaddrinfo failed for {address}: {e}")
785+
786+
for res in addr_infos:
787+
af, socktype, proto, canonname, sa = res
788+
sock = None
789+
try:
790+
sock = socket(af, socktype, proto)
791+
792+
# Standard server socket option
793+
sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
794+
795+
if reuse_port:
796+
if hasattr(socket, "SO_REUSEPORT"):
797+
sock.setsockopt(SOL_SOCKET, SO_REUSEPORT, 1)
798+
799+
sock.bind(sa)
800+
sock.listen(backlog if backlog is not None else SOMAXCONN)
801+
802+
return sock # Success!
803+
804+
except OSError as e:
805+
err = e
806+
if sock is not None:
807+
sock.close()
808+
809+
# If we get here, no address worked
810+
if err is not None:
811+
raise err
812+
raise OSError(f"Could not create server on {address}")

‎Lib/test/test_httplib.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66
import array
77
import socket
8+
import threading
89

910
import unittest
1011
TestCase = unittest.TestCase
@@ -1167,6 +1168,72 @@ def run_server():
11671168
thread.join()
11681169
self.assertEqual(result, b"proxied data\n")
11691170

1171+
def test_large_content_length(self):
1172+
serv = socket.create_server((HOST, 0))
1173+
self.addCleanup(serv.close)
1174+
1175+
def run_server():
1176+
[conn, address] = serv.accept()
1177+
with conn:
1178+
while conn.recv(1024):
1179+
conn.sendall(
1180+
b"HTTP/1.1 200 Ok\r\n"
1181+
b"Content-Length: %d\r\n"
1182+
b"\r\n" % size)
1183+
conn.sendall(b'A' * (size//3))
1184+
conn.sendall(b'B' * (size - size//3))
1185+
1186+
thread = threading.Thread(target=run_server)
1187+
thread.start()
1188+
self.addCleanup(thread.join, 1.0)
1189+
1190+
conn = client.HTTPConnection(*serv.getsockname())
1191+
try:
1192+
for w in range(15, 27):
1193+
size = 1 << w
1194+
conn.request("GET", "/")
1195+
with conn.getresponse() as response:
1196+
self.assertEqual(len(response.read()), size)
1197+
finally:
1198+
conn.close()
1199+
thread.join(1.0)
1200+
1201+
def test_large_content_length_truncated(self):
1202+
serv = socket.create_server((HOST, 0))
1203+
self.addCleanup(serv.close)
1204+
1205+
def run_server():
1206+
while True:
1207+
[conn, address] = serv.accept()
1208+
with conn:
1209+
conn.recv(1024)
1210+
if not size:
1211+
break
1212+
conn.sendall(
1213+
b"HTTP/1.1 200 Ok\r\n"
1214+
b"Content-Length: %d\r\n"
1215+
b"\r\n"
1216+
b"Text" % size)
1217+
1218+
thread = threading.Thread(target=run_server)
1219+
thread.start()
1220+
self.addCleanup(thread.join, 1.0)
1221+
1222+
conn = client.HTTPConnection(*serv.getsockname())
1223+
try:
1224+
for w in range(18, 65):
1225+
size = 1 << w
1226+
conn.request("GET", "/")
1227+
with conn.getresponse() as response:
1228+
self.assertRaises(client.IncompleteRead, response.read)
1229+
conn.close()
1230+
finally:
1231+
conn.close()
1232+
size = 0
1233+
conn.request("GET", "/")
1234+
conn.close()
1235+
thread.join(1.0)
1236+
11701237
def test_putrequest_override_domain_validation(self):
11711238
"""
11721239
It should be possible to override the default validation
@@ -1991,3 +2058,4 @@ def test_tunnel_debuglog(self):
19912058

19922059
if __name__ == '__main__':
19932060
unittest.main(verbosity=2)
2061+
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.client` module.
2+
When connecting to a malicious server, 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)