Skip to content

Commit 63a539f

Browse files
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.
1 parent fdec4df commit 63a539f

3 files changed

Lines changed: 50 additions & 5 deletions

File tree

‎Lib/http/client.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -396,11 +396,16 @@ def begin(self, *, _max_headers=None):
396396
# do we have a Content-Length?
397397
# NOTE: RFC 2616, S4.4, #3 says we ignore this if tr_enc is "chunked"
398398
self.length = None
399-
length = self.headers.get("content-length")
400-
if length is not None:
401-
# RFC 9112, section 5.1: optional whitespace (SP / HTAB) around
402-
# the field value is not part of the value.
403-
length = length.strip(' \t')
399+
# RFC 9112, section 6.3: a Content-Length that is a comma-separated
400+
# list (or, per RFC 9110, section 5.3, a repeated field) is only
401+
# valid if all the values are the same, and is then used as that
402+
# single value; otherwise the message framing is invalid.
403+
# RFC 9112, section 5.1: optional whitespace (SP / HTAB) around
404+
# the field value is not part of the value.
405+
lengths = {value.strip(' \t')
406+
for field in self.headers.get_all("content-length", ())
407+
for value in field.split(',')}
408+
length = lengths.pop() if len(lengths) == 1 else None
404409
if length and not self.chunked and _is_legal_content_length(length):
405410
self.length = int(length)
406411
else:

‎Lib/test/test_httplib.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1377,6 +1377,40 @@ def test_content_length_with_whitespace(self):
13771377
self.assertTrue(resp.isclosed())
13781378
resp.close()
13791379

1380+
def test_duplicate_content_length(self):
1381+
# RFC 9112, section 6.3: a comma-separated list of identical
1382+
# Content-Length values, or (RFC 9110, section 5.3) a repeated
1383+
# field, is processed with that single value.
1384+
for fields in ('Content-Length: 7\r\nContent-Length: 7\r\n',
1385+
'Content-Length: 7, 7\r\n',
1386+
'Content-Length: 7 ,7\r\nContent-Length: 7\r\n'):
1387+
with self.subTest(fields=fields):
1388+
sock = FakeSocket(
1389+
'HTTP/1.1 200 OK\r\n%s\r\nHello\r\nextra' % fields)
1390+
resp = client.HTTPResponse(sock, method="GET")
1391+
resp.begin()
1392+
self.assertEqual(resp.length, 7)
1393+
self.assertEqual(resp.read(), b'Hello\r\n')
1394+
self.assertTrue(resp.isclosed())
1395+
resp.close()
1396+
1397+
def test_conflicting_content_length(self):
1398+
# RFC 9112, section 6.3: differing Content-Length values make the
1399+
# message framing invalid, so none of them may be used to frame
1400+
# the body, and the connection must not be reused.
1401+
for fields in ('Content-Length: 5\r\nContent-Length: 7\r\n',
1402+
'Content-Length: 7\r\nContent-Length: 5\r\n',
1403+
'Content-Length: 5, 7\r\n'):
1404+
with self.subTest(fields=fields):
1405+
sock = FakeSocket(
1406+
'HTTP/1.1 200 OK\r\n%s\r\nHello\r\n' % fields)
1407+
resp = client.HTTPResponse(sock, method="GET")
1408+
resp.begin()
1409+
self.assertIsNone(resp.length)
1410+
self.assertTrue(resp.will_close)
1411+
self.assertEqual(resp.read(), b'Hello\r\n')
1412+
resp.close()
1413+
13801414
def test_malformed_chunk_size(self):
13811415
# RFC 9112: chunk-size = 1*HEXDIG. Reject sizes that int(_, 16) accepts
13821416
# but the grammar forbids (a sign, an "0x" prefix, underscores or
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
:mod:`http.client` now handles a repeated or comma-separated
2+
``Content-Length`` header as required by :rfc:`9112`: identical values
3+
frame the response body like a single header, while differing values are
4+
treated as an invalid header and the body is read until the connection is
5+
closed. Previously the first of several ``Content-Length`` headers was
6+
used, which could leave part of the body unread on a reused connection.

0 commit comments

Comments
 (0)