https://github.com/python/cpython/commit/2eb2af805c156ca52b316bb4ced5e0ab3971d0ae
commit: 2eb2af805c156ca52b316bb4ced5e0ab3971d0ae
branch: main
author: metsw24-max <[email protected]>
committer: encukou <[email protected]>
date: 2026-10-08T14:40:51+02:00
summary:

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.

files:
A Misc/NEWS.d/next/Library/2026-06-02-13-10-00.gh-issue-150751.Hk2Vw9.rst
M Lib/http/client.py
M Lib/test/test_httplib.py

diff --git a/Lib/http/client.py b/Lib/http/client.py
index 7ef99e7201c005c..4b6fc549a9b5179 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'}
@@ -392,14 +397,8 @@ def begin(self, *, _max_headers=None):
         # 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
 
@@ -566,7 +565,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 5b1d6e0aa520794..ad28e921978e98f 100644
--- a/Lib/test/test_httplib.py
+++ b/Lib/test/test_httplib.py
@@ -1348,6 +1348,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 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.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to