From 1c8d928392cf0b4e84e392eb12f34557c9a0373f Mon Sep 17 00:00:00 2001 From: metsw24-max Date: Thu, 8 Oct 2026 18:10:51 +0530 Subject: [PATCH 1/2] gh-150751: validate http.client Content-Length and chunk-size (GH-150752) RFC 9112 defines Content-Length as 1*DIGIT and chunk-size as 1*HEXDIG, but int() also accepts a sign, underscores, surrounding whitespace and an 0x prefix, so malformed framing values were parsed instead of rejected. (cherry picked from commit 2eb2af805c156ca52b316bb4ced5e0ab3971d0ae) Co-authored-by: metsw24-max --- Lib/http/client.py | 18 +++++++------ Lib/test/test_httplib.py | 27 +++++++++++++++++++ ...-06-02-13-10-00.gh-issue-150751.Hk2Vw9.rst | 5 ++++ 3 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-02-13-10-00.gh-issue-150751.Hk2Vw9.rst diff --git a/Lib/http/client.py b/Lib/http/client.py index 3a9eb406d62021..ebcfff301d4d5b 100644 --- a/Lib/http/client.py +++ b/Lib/http/client.py @@ -165,6 +165,11 @@ # to prevent http header injection. _contains_disallowed_method_pchar_re = re.compile('[\x00-\x1f]') +# RFC 9112: Content-Length = 1*DIGIT and chunk-size = 1*HEXDIG. +# int() is more permissive, so we match against the grammar before calling it. +_is_legal_content_length = re.compile(r'[0-9]+').fullmatch +_is_legal_chunk_size = re.compile(rb'[0-9a-fA-F]+').fullmatch + # We always set the Content-Length header for these methods because some # servers will otherwise respond with a 411 _METHODS_EXPECTING_BODY = {'PATCH', 'POST', 'PUT'} @@ -383,14 +388,8 @@ def begin(self): # 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 and not self.chunked: - try: - self.length = int(length) - except ValueError: - self.length = None - else: - if self.length < 0: # ignore nonsensical negative lengths - self.length = None + if length and not self.chunked and _is_legal_content_length(length): + self.length = int(length) else: self.length = None @@ -557,7 +556,10 @@ def _read_next_chunk_size(self): i = line.find(b";") if i >= 0: line = line[:i] # strip chunk-extensions + line = line.rstrip() try: + if not _is_legal_chunk_size(line): + raise ValueError("invalid chunk size") return int(line, 16) except ValueError: # close the connection as protocol synchronisation is diff --git a/Lib/test/test_httplib.py b/Lib/test/test_httplib.py index 52290b37a2de17..ae8c7681ed70d7 100644 --- a/Lib/test/test_httplib.py +++ b/Lib/test/test_httplib.py @@ -1260,6 +1260,33 @@ def test_negative_content_length(self): self.assertEqual(resp.read(), b'Hello\r\n') self.assertTrue(resp.isclosed()) + def test_malformed_content_length(self): + # RFC 9112: Content-Length = 1*DIGIT. Values that int() accepts but + # the grammar forbids must not be used to frame the body. + for value in ('+5', '5_0'): + with self.subTest(value=value): + sock = FakeSocket( + 'HTTP/1.1 200 OK\r\nContent-Length: %s\r\n\r\nHello\r\n' % value) + resp = client.HTTPResponse(sock, method="GET") + resp.begin() + self.assertIsNone(resp.length) + 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 + # leading whitespace). + start = 'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n' + for size in ('-5', '+5', '0x5', '1_f', ' 5'): + with self.subTest(size=size): + sock = FakeSocket(start + '%s\r\nHELLO\r\n0\r\n\r\n' % size) + resp = client.HTTPResponse(sock, method="GET") + resp.begin() + self.assertRaises(client.IncompleteRead, resp.read) + self.assertTrue(resp.isclosed()) + resp.close() + def test_incomplete_read(self): sock = FakeSocket('HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nHello\r\n') resp = client.HTTPResponse(sock, method="GET") diff --git a/Misc/NEWS.d/next/Library/2026-06-02-13-10-00.gh-issue-150751.Hk2Vw9.rst b/Misc/NEWS.d/next/Library/2026-06-02-13-10-00.gh-issue-150751.Hk2Vw9.rst new file mode 100644 index 00000000000000..670d677aff79ff --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-02-13-10-00.gh-issue-150751.Hk2Vw9.rst @@ -0,0 +1,5 @@ +:mod:`http.client` now validates the ``Content-Length`` header and the +chunked ``chunk-size`` against the RFC 9112 grammar (``1*DIGIT`` and +``1*HEXDIG``) before parsing them, rejecting values such as ``+5``, ``5_0`` +or a ``0x``-prefixed or negative chunk size that :func:`int` would otherwise +accept. This avoids framing a response differently from a strict peer. From 6dd8f07d04e834fbdd1bf92841c8e6488a18b667 Mon Sep 17 00:00:00 2001 From: blago1 <74470460+danilablagorodniy37@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:50:35 +0100 Subject: [PATCH 2/2] gh-150751: Ignore optional whitespace around Content-Length in http.client (GH-159156) gh-150752 started validating the Content-Length value against the RFC 9112 grammar (1*DIGIT), but the compat32 header parser keeps trailing SP/HTAB in the field value, so "Content-Length: 5 " is now treated as a missing header. HTTPResponse then assumes that the connection will close and read() blocks until the server closes it, which never happens on a keep-alive connection. RFC 9112, section 5.1 says that optional whitespace around a field value is excluded by parsers. Strip SP/HTAB before the grammar check; values such as "+5", "5_0" and "5 0" are still rejected. --- Lib/http/client.py | 4 ++++ Lib/test/test_httplib.py | 18 +++++++++++++++++- ...6-10-10-22-14-22.gh-issue-150751.JInuGL.rst | 5 +++++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-10-22-14-22.gh-issue-150751.JInuGL.rst diff --git a/Lib/http/client.py b/Lib/http/client.py index ebcfff301d4d5b..798d117a9c6080 100644 --- a/Lib/http/client.py +++ b/Lib/http/client.py @@ -388,6 +388,10 @@ def begin(self): # 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') 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 ae8c7681ed70d7..6e605ddcc4f1af 100644 --- a/Lib/test/test_httplib.py +++ b/Lib/test/test_httplib.py @@ -1263,7 +1263,7 @@ def test_negative_content_length(self): def test_malformed_content_length(self): # RFC 9112: Content-Length = 1*DIGIT. Values that int() accepts but # the grammar forbids must not be used to frame the body. - for value in ('+5', '5_0'): + for value in ('+5', '5_0', '5 0'): with self.subTest(value=value): sock = FakeSocket( 'HTTP/1.1 200 OK\r\nContent-Length: %s\r\n\r\nHello\r\n' % value) @@ -1273,6 +1273,22 @@ def test_malformed_content_length(self): self.assertEqual(resp.read(), b'Hello\r\n') resp.close() + def test_content_length_with_whitespace(self): + # RFC 9112, section 5.1: optional whitespace (SP / HTAB) around + # the field value is not part of the value, so it must not + # prevent the Content-Length from being used to frame the body. + for value in ('7 ', '7\t', '7 \t ', ' 7 '): + with self.subTest(value=value): + sock = FakeSocket( + 'HTTP/1.1 200 OK\r\nContent-Length: %s\r\n\r\n' + 'Hello\r\nextra' % value) + 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_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-10-22-14-22.gh-issue-150751.JInuGL.rst b/Misc/NEWS.d/next/Library/2026-10-10-22-14-22.gh-issue-150751.JInuGL.rst new file mode 100644 index 00000000000000..40c8d606aa6773 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-10-22-14-22.gh-issue-150751.JInuGL.rst @@ -0,0 +1,5 @@ +:mod:`http.client` now ignores optional whitespace around the +``Content-Length`` header value when validating it, as required by +:rfc:`9112`. Previously a value with trailing whitespace (for example +``Content-Length: 5`` followed by a space) was treated as missing and the +response body was read until the connection was closed.