From 06870175a5a703d38dc9c88c4d9d51b60a12a04e Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Sat, 30 May 2026 04:14:12 +0000 Subject: [PATCH 1/5] 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 (max_response_headers, 100 by default) and HTTPException is raised when the limit is exceeded. --- Doc/library/http.client.rst | 1 + Lib/http/client.py | 17 ++++++ Lib/test/test_httplib.py | 58 +++++++++++++++++++ ...05-30-00-00-00.gh-issue-000000.httpdos.rst | 6 ++ 4 files changed, 82 insertions(+) create mode 100644 Misc/NEWS.d/next/Security/2026-05-30-00-00-00.gh-issue-000000.httpdos.rst diff --git a/Doc/library/http.client.rst b/Doc/library/http.client.rst index ddf3d40d221fcd..98ea09d8f72d8b 100644 --- a/Doc/library/http.client.rst +++ b/Doc/library/http.client.rst @@ -433,6 +433,7 @@ HTTPConnection Objects The maximum number of allowed response headers to help prevent denial-of-service attacks. By default, the maximum number of allowed headers is set to 100. + The same limit applies to the trailer section of a chunked response. .. versionadded:: 3.15 diff --git a/Lib/http/client.py b/Lib/http/client.py index 1e1a535c4c4eb1..8d6f7cbdc4c8d2 100644 --- a/Lib/http/client.py +++ b/Lib/http/client.py @@ -293,6 +293,7 @@ def __init__(self, sock, debuglevel=0, method=None, url=None): self.chunk_left = _UNKNOWN # bytes left to read in current chunk self.length = _UNKNOWN # number of bytes left in response self.will_close = _UNKNOWN # conn will close at end of response + self._max_headers = None # configured header count limit def _read_status(self): line = str(self.fp.readline(_MAXLINE + 1), "iso-8859-1") @@ -332,6 +333,11 @@ def begin(self, *, _max_headers=None): # we've already started reading the response return + # Trailers of a chunked response are read by read() long after + # begin() returns, so remember the configured header count limit + # for _read_and_discard_trailer() to enforce. + self._max_headers = _max_headers + # read until we get a non-100 response while True: version, status, reason = self._read_status() @@ -561,6 +567,10 @@ 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! + max_trailers = self._max_headers + if max_trailers is None: + max_trailers = _MAXHEADERS + trailers_read = 0 while True: line = self.fp.readline(_MAXLINE + 1) if len(line) > _MAXLINE: @@ -571,6 +581,13 @@ 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 > max_trailers: + raise HTTPException(f"got more than {max_trailers} 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 f771fc48dada36..f2bab290fce797 100644 --- a/Lib/test/test_httplib.py +++ b/Lib/test/test_httplib.py @@ -478,6 +478,35 @@ def test_max_connection_headers(self): response = conn.getresponse() response.read() + def test_max_connection_trailers(self): + # max_response_headers also limits trailer lines of a chunked + # response, which are read and discarded by read(). + max_trailers = client._MAXHEADERS + 20 + trailer_lines = "".join( + f"X-Trailer{i}: {i}\r\n" for i in range(max_trailers - 1) + ) + body = chunked_start + last_chunk + trailer_lines + chunked_end + + with self.subTest(max_response_headers=None): + conn = client.HTTPConnection("example.com") + conn.sock = FakeSocket(body) + conn.request("GET", "/") + response = conn.getresponse() + with self.assertRaisesRegex( + client.HTTPException, + f"got more than {client._MAXHEADERS} trailers", + ): + response.read() + + with self.subTest(max_response_headers=max_trailers): + conn = client.HTTPConnection( + "example.com", max_response_headers=max_trailers + ) + conn.sock = FakeSocket(body) + conn.request("GET", "/") + response = conn.getresponse() + self.assertEqual(response.read(), chunked_expected) + class HttpMethodTests(TestCase): def test_invalid_method_names(self): methods = ( @@ -1449,6 +1478,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-000000.httpdos.rst b/Misc/NEWS.d/next/Security/2026-05-30-00-00-00.gh-issue-000000.httpdos.rst new file mode 100644 index 00000000000000..0667f989c16d21 --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-05-30-00-00-00.gh-issue-000000.httpdos.rst @@ -0,0 +1,6 @@ +:mod:`http.client` now limits the number of chunked-response trailer lines +it will read to :attr:`~http.client.HTTPConnection.max_response_headers` +(100 by default), 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 9fc31d43e047cfbd5eae02c6d6dbbb39048d81d0 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Sat, 30 May 2026 04:16:37 +0000 Subject: [PATCH 2/5] 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. --- 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 8d6f7cbdc4c8d2..7ef99e7201c005 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 @@ -339,7 +346,7 @@ def begin(self, *, _max_headers=None): self._max_headers = _max_headers # read until we get a non-100 response - while True: + for _ in range(_MAXINTERIMRESPONSES): version, status, reason = self._read_status() if status != CONTINUE: break @@ -348,6 +355,9 @@ def begin(self, *, _max_headers=None): 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 f2bab290fce797..5b1d6e0aa52079 100644 --- a/Lib/test/test_httplib.py +++ b/Lib/test/test_httplib.py @@ -1407,6 +1407,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' From 7c5757bdc122b5853b20576331ac1856899f17e9 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Tue, 2 Jun 2026 01:52:30 +0000 Subject: [PATCH 3/5] Use the real issue number in the NEWS filename --- ...ttpdos.rst => 2026-05-30-00-00-00.gh-issue-150743.httpdos.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Misc/NEWS.d/next/Security/{2026-05-30-00-00-00.gh-issue-000000.httpdos.rst => 2026-05-30-00-00-00.gh-issue-150743.httpdos.rst} (100%) diff --git a/Misc/NEWS.d/next/Security/2026-05-30-00-00-00.gh-issue-000000.httpdos.rst b/Misc/NEWS.d/next/Security/2026-05-30-00-00-00.gh-issue-150743.httpdos.rst similarity index 100% rename from Misc/NEWS.d/next/Security/2026-05-30-00-00-00.gh-issue-000000.httpdos.rst rename to Misc/NEWS.d/next/Security/2026-05-30-00-00-00.gh-issue-150743.httpdos.rst From 063ff61018fda95caaaa63e6d9a79d22fe32fa95 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" <68491+gpshead@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:11:08 -0700 Subject: [PATCH 4/5] credit YLChen-007 for the report per confirmation --- .../Security/2026-05-30-00-00-00.gh-issue-150743.httpdos.rst | 1 + 1 file changed, 1 insertion(+) 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 index 0667f989c16d21..8aa65c0b55e619 100644 --- 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 @@ -4,3 +4,4 @@ it will read to :attr:`~http.client.HTTPConnection.max_response_headers` 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. +Reported by ``@YLChen-007`` via GHSA-w4q2-g22w-6fr4. From 8c2cd9e8f7306a931b108c4127fc51622a8f5fd3 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" <68491+gpshead@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:12:30 -0700 Subject: [PATCH 5/5] remove trailing space --- .../Security/2026-05-30-00-00-00.gh-issue-150743.httpdos.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 8aa65c0b55e619..abc07d91c63fca 100644 --- 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 @@ -4,4 +4,4 @@ it will read to :attr:`~http.client.HTTPConnection.max_response_headers` 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. -Reported by ``@YLChen-007`` via GHSA-w4q2-g22w-6fr4. +Reported by ``@YLChen-007`` via GHSA-w4q2-g22w-6fr4.