From 63a539fa82cb322619f075b4a1dd8d24887db548 Mon Sep 17 00:00:00 2001 From: blago1 <74470460+danilablagorodniy37@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:59:10 +0100 Subject: [PATCH] gh-150751: Handle duplicate Content-Length headers in http.client HTTPResponse.begin() framed the body with the first Content-Length header and ignored any others, so a response with two differing values was read using the first one while the rest of the body stayed on the connection and corrupted the next response on a keep-alive connection. A comma-separated list of identical values ("7, 7"), which a proxy produces when it combines repeated fields, was treated as malformed and the body was read until the connection closed. RFC 9112, section 6.3 says that an invalid Content-Length makes the message framing invalid, unless it is a comma-separated list of identical values, which is then used as that single value; repeated fields are equivalent to such a list (RFC 9110, section 5.3). Collect the distinct values: a single one is used as before, several are treated like an invalid header (the body is read until close and the connection is not reused), as Go and h11 do. --- Lib/http/client.py | 15 +++++--- Lib/test/test_httplib.py | 34 +++++++++++++++++++ ...-10-15-21-54-33.gh-issue-150751.VrtqAh.rst | 6 ++++ 3 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-15-21-54-33.gh-issue-150751.VrtqAh.rst diff --git a/Lib/http/client.py b/Lib/http/client.py index 21484b835a08ff9..27d3d4b1414ea45 100644 --- a/Lib/http/client.py +++ b/Lib/http/client.py @@ -396,11 +396,16 @@ def begin(self, *, _max_headers=None): # do we have a Content-Length? # NOTE: RFC 2616, S4.4, #3 says we ignore this if tr_enc is "chunked" self.length = None - length = self.headers.get("content-length") - if length is not None: - # RFC 9112, section 5.1: optional whitespace (SP / HTAB) around - # the field value is not part of the value. - length = length.strip(' \t') + # RFC 9112, section 6.3: a Content-Length that is a comma-separated + # list (or, per RFC 9110, section 5.3, a repeated field) is only + # valid if all the values are the same, and is then used as that + # single value; otherwise the message framing is invalid. + # RFC 9112, section 5.1: optional whitespace (SP / HTAB) around + # the field value is not part of the value. + lengths = {value.strip(' \t') + for field in self.headers.get_all("content-length", ()) + for value in field.split(',')} + length = lengths.pop() if len(lengths) == 1 else None if length and not self.chunked and _is_legal_content_length(length): self.length = int(length) else: diff --git a/Lib/test/test_httplib.py b/Lib/test/test_httplib.py index 1e5ec0d4e43c271..9ec1614287dff0b 100644 --- a/Lib/test/test_httplib.py +++ b/Lib/test/test_httplib.py @@ -1377,6 +1377,40 @@ def test_content_length_with_whitespace(self): self.assertTrue(resp.isclosed()) resp.close() + def test_duplicate_content_length(self): + # RFC 9112, section 6.3: a comma-separated list of identical + # Content-Length values, or (RFC 9110, section 5.3) a repeated + # field, is processed with that single value. + for fields in ('Content-Length: 7\r\nContent-Length: 7\r\n', + 'Content-Length: 7, 7\r\n', + 'Content-Length: 7 ,7\r\nContent-Length: 7\r\n'): + with self.subTest(fields=fields): + sock = FakeSocket( + 'HTTP/1.1 200 OK\r\n%s\r\nHello\r\nextra' % fields) + resp = client.HTTPResponse(sock, method="GET") + resp.begin() + self.assertEqual(resp.length, 7) + self.assertEqual(resp.read(), b'Hello\r\n') + self.assertTrue(resp.isclosed()) + resp.close() + + def test_conflicting_content_length(self): + # RFC 9112, section 6.3: differing Content-Length values make the + # message framing invalid, so none of them may be used to frame + # the body, and the connection must not be reused. + for fields in ('Content-Length: 5\r\nContent-Length: 7\r\n', + 'Content-Length: 7\r\nContent-Length: 5\r\n', + 'Content-Length: 5, 7\r\n'): + with self.subTest(fields=fields): + sock = FakeSocket( + 'HTTP/1.1 200 OK\r\n%s\r\nHello\r\n' % fields) + resp = client.HTTPResponse(sock, method="GET") + resp.begin() + self.assertIsNone(resp.length) + self.assertTrue(resp.will_close) + self.assertEqual(resp.read(), b'Hello\r\n') + resp.close() + def test_malformed_chunk_size(self): # RFC 9112: chunk-size = 1*HEXDIG. Reject sizes that int(_, 16) accepts # but the grammar forbids (a sign, an "0x" prefix, underscores or diff --git a/Misc/NEWS.d/next/Library/2026-10-15-21-54-33.gh-issue-150751.VrtqAh.rst b/Misc/NEWS.d/next/Library/2026-10-15-21-54-33.gh-issue-150751.VrtqAh.rst new file mode 100644 index 000000000000000..67d9c8b626b2f37 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-15-21-54-33.gh-issue-150751.VrtqAh.rst @@ -0,0 +1,6 @@ +:mod:`http.client` now handles a repeated or comma-separated +``Content-Length`` header as required by :rfc:`9112`: identical values +frame the response body like a single header, while differing values are +treated as an invalid header and the body is read until the connection is +closed. Previously the first of several ``Content-Length`` headers was +used, which could leave part of the body unread on a reused connection.