From 63b37eb33130a659710c29e3c37318c30a97014c Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Sat, 30 May 2026 04:23:42 +0000 Subject: [PATCH 1/2] http.client: bound the number of chunked trailer lines read A server could stream syntactically valid trailer lines forever after the final chunk of a chunked response, so reading the response would never return. Socket timeouts cannot interrupt this because data keeps arriving within every timeout window. Trailer lines are now counted against the same limit as response headers (100 by default) and HTTPException is raised when the limit is exceeded. (cherry picked from commit 752bef5047e54e21af0e48a339b54fbae2a6c831) --- Lib/http/client.py | 9 ++++++ Lib/test/test_httplib.py | 29 +++++++++++++++++++ ...05-30-00-00-00.gh-issue-150743.httpdos.rst | 5 ++++ 3 files changed, 43 insertions(+) create mode 100644 Misc/NEWS.d/next/Security/2026-05-30-00-00-00.gh-issue-150743.httpdos.rst diff --git a/Lib/http/client.py b/Lib/http/client.py index 6fb7d254ea9c27..08e852b22c51d4 100644 --- a/Lib/http/client.py +++ b/Lib/http/client.py @@ -558,6 +558,7 @@ def _read_next_chunk_size(self): def _read_and_discard_trailer(self): # read and discard trailer up to the CRLF terminator ### note: we shouldn't have any trailers! + trailers_read = 0 while True: line = self.fp.readline(_MAXLINE + 1) if len(line) > _MAXLINE: @@ -568,6 +569,14 @@ def _read_and_discard_trailer(self): break if line in (b'\r\n', b'\n', b''): break + # Bound the trailer count just as response headers are bounded. + # A server streaming trailer lines forever would otherwise hang + # the client; a socket timeout cannot detect that as data keeps + # arriving within every timeout window. + trailers_read += 1 + if trailers_read > _MAXHEADERS: + raise HTTPException( + f"got more than {_MAXHEADERS} trailers") def _get_chunk_left(self): # return self.chunk_left, reading a new chunk if necessary. diff --git a/Lib/test/test_httplib.py b/Lib/test/test_httplib.py index 6f3eac6b98a4de..9172bf7a28cc74 100644 --- a/Lib/test/test_httplib.py +++ b/Lib/test/test_httplib.py @@ -1403,6 +1403,35 @@ def test_chunked_trailers(self): self.assertEqual(sock.file.read(), b"") #we read to the end resp.close() + def test_chunked_too_many_trailers(self): + """A response streaming endless trailer lines must raise, not hang""" + too_many_trailers = "".join( + f"X-Trailer{i}: {i}\r\n" for i in range(client._MAXHEADERS + 1) + ) + # An unbounded read() reaches the trailers via the final 0 chunk. + sock = FakeSocket( + chunked_start + last_chunk + too_many_trailers + chunked_end) + resp = client.HTTPResponse(sock, method="GET") + resp.begin() + with self.assertRaisesRegex( + client.HTTPException, + f"got more than {client._MAXHEADERS} trailers", + ): + resp.read() + resp.close() + + # A bounded read(amt) larger than the body hits the same limit. + sock = FakeSocket( + chunked_start + last_chunk + too_many_trailers + chunked_end) + resp = client.HTTPResponse(sock, method="GET") + resp.begin() + with self.assertRaisesRegex( + client.HTTPException, + f"got more than {client._MAXHEADERS} trailers", + ): + resp.read(len(chunked_expected) + 1) + resp.close() + def test_chunked_sync(self): """Check that we don't read past the end of the chunked-encoding stream""" expected = chunked_expected diff --git a/Misc/NEWS.d/next/Security/2026-05-30-00-00-00.gh-issue-150743.httpdos.rst b/Misc/NEWS.d/next/Security/2026-05-30-00-00-00.gh-issue-150743.httpdos.rst new file mode 100644 index 00000000000000..9cd6f69c24c6ea --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-05-30-00-00-00.gh-issue-150743.httpdos.rst @@ -0,0 +1,5 @@ +:mod:`http.client` now limits the number of chunked-response trailer lines +it will read to 100, and the number of interim (1xx) responses it will +skip to 100. A malicious or broken server could previously stream trailer +lines or ``100 Continue`` responses forever, hanging the client even when +a socket timeout was in use. From 27a23f9958f53d76c77573f63e6624053c66b41c Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Sat, 30 May 2026 04:24:07 +0000 Subject: [PATCH 2/2] http.client: bound the number of interim 1xx responses skipped HTTPResponse.begin() skipped 100 Continue responses in an unbounded loop, so a server streaming them forever would hang getresponse() regardless of any socket timeout. At most 100 interim responses are now skipped before HTTPException is raised. (cherry picked from commit 508e86d8811e8e17350bf2c6fb23d3bb2d3ff795) --- Lib/http/client.py | 12 +++++++++++- Lib/test/test_httplib.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/Lib/http/client.py b/Lib/http/client.py index 08e852b22c51d4..50f7862d3d2ab8 100644 --- a/Lib/http/client.py +++ b/Lib/http/client.py @@ -111,6 +111,13 @@ _MAXLINE = 65536 _MAXHEADERS = 100 +# maximal number of interim (1xx) responses tolerated before the final +# response. Real servers send at most a few; without a bound, a server +# streaming "100 Continue" responses would hang getresponse() forever. +# A socket timeout cannot detect that as data keeps arriving within every +# timeout window. +_MAXINTERIMRESPONSES = 100 + # Data larger than this will be read in chunks, to prevent extreme # overallocation. _MIN_READ_BUF_SIZE = 1 << 20 @@ -332,7 +339,7 @@ def begin(self): return # read until we get a non-100 response - while True: + for _ in range(_MAXINTERIMRESPONSES): version, status, reason = self._read_status() if status != CONTINUE: break @@ -341,6 +348,9 @@ def begin(self): if self.debuglevel > 0: print("headers:", skipped_headers) del skipped_headers + else: + raise HTTPException( + f"got more than {_MAXINTERIMRESPONSES} interim responses") self.code = self.status = status self.reason = reason.strip() diff --git a/Lib/test/test_httplib.py b/Lib/test/test_httplib.py index 9172bf7a28cc74..0aaf09b26315ed 100644 --- a/Lib/test/test_httplib.py +++ b/Lib/test/test_httplib.py @@ -1332,6 +1332,35 @@ def test_overflowing_header_limit_after_100(self): self.assertIn('got more than ', str(cm.exception)) self.assertIn('headers', str(cm.exception)) + def test_too_many_interim_responses(self): + # A server streaming "100 Continue" responses forever must not + # hang getresponse(). + body = ( + 'HTTP/1.1 100 Continue\r\n\r\n' + * (client._MAXINTERIMRESPONSES + 1) + ) + resp = client.HTTPResponse(FakeSocket(body)) + with self.assertRaises(client.HTTPException) as cm: + resp.begin() + self.assertIn('got more than ', str(cm.exception)) + self.assertIn('interim responses', str(cm.exception)) + + def test_multiple_interim_responses(self): + # A reasonable number of interim responses before the final + # response is skipped as before. + body = ( + 'HTTP/1.1 100 Continue\r\n\r\n' * 3 + + 'HTTP/1.1 200 OK\r\n' + 'Content-Length: 5\r\n' + '\r\n' + 'hello' + ) + resp = client.HTTPResponse(FakeSocket(body), method="GET") + resp.begin() + self.assertEqual(resp.status, 200) + self.assertEqual(resp.read(), b'hello') + resp.close() + def test_overflowing_chunked_line(self): body = ( 'HTTP/1.1 200 OK\r\n'