diff --git a/Lib/http/client.py b/Lib/http/client.py index 8ada5cbd7446ec9..60e2207f7d8f33f 100644 --- a/Lib/http/client.py +++ b/Lib/http/client.py @@ -166,6 +166,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'} @@ -384,14 +389,12 @@ 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 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: self.length = None @@ -558,7 +561,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 705f636d422a747..38922685788161c 100644 --- a/Lib/test/test_httplib.py +++ b/Lib/test/test_httplib.py @@ -1273,6 +1273,49 @@ 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', '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_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 + # 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 000000000000000..670d677aff79ff2 --- /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. 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 000000000000000..40c8d606aa67734 --- /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.