Skip to content

gh-150751: Handle duplicate Content-Length headers in http.client - #159495

Open
danilablagorodniy37 wants to merge 1 commit into
python:mainfrom
danilablagorodniy37:main
Open

danilablagorodniy37 wants to merge 1 commit into
python:mainfrom
danilablagorodniy37:main

Conversation

@danilablagorodniy37

@danilablagorodniy37 danilablagorodniy37 commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

HTTPResponse.begin() reads the Content-Length with self.headers.get(), which returns the first field of that name, so a response carrying two differing Content-Length headers is framed with the first value and the other headers are silently ignored. The rest of the body then stays on the socket, and on a keep-alive connection the next response is corrupted. A comma-separated list of identical values (Content-Length: 7, 7, which an intermediary produces when it combines repeated fields) fails the 1*DIGIT check instead and is treated as a missing header, so the body is read until the server closes the connection:

import http.client, socket, threading, time

def handle(conn):
    with conn:
        conn.settimeout(1)  # keep-alive idle timeout
        try:
            while True:
                req = b""
                while b"\r\n\r\n" not in req:
                    req += conn.recv(65536)
                if b"/first" in req:
                    conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nContent-Length: 10\r\n\r\nHello")
                    time.sleep(0.3)
                    conn.sendall(b"World")  # the rest of the body arrives later
                else:
                    conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\nSecond")
        except TimeoutError:
            return

def serve():
    while True:
        conn, _ = srv.accept()
        threading.Thread(target=handle, args=(conn,), daemon=True).start()

srv = socket.create_server(("127.0.0.1", 0))
threading.Thread(target=serve, daemon=True).start()

c = http.client.HTTPConnection(*srv.getsockname(), timeout=5)
c.request("GET", "/first"); r = c.getresponse()
print(r.length, r.will_close, r.read())   # main: 5 False b'Hello'      PR: None True b'HelloWorld'
time.sleep(0.5)
c.request("GET", "/second"); r = c.getresponse()
print(r.status, r.read())                 # main: BadStatusLine("WorldHTTP/1.1 200 OK\r\n")
                                          # PR:   200 b'Second' (on a new connection)

RFC 9110, section 5.3 makes repeated field lines equivalent to one comma-separated list, and Content-Length = 1*DIGIT, so such a list is an invalid value; RFC 9110, section 8.6 lets a recipient of "the same decimal value repeated as a comma-separated list" (Content-Length: 42, 42) "either reject the message as invalid or replace that invalid field value with a single instance of the decimal value". RFC 9112, section 6.3 then says that an invalid Content-Length makes the message framing invalid and "the recipient MUST treat it as an unrecoverable error, unless the field value can be successfully parsed as a comma-separated list [...], all values in the list are valid, and all values in the list are the same (in which case, the message is processed with that single value used as the Content-Length field value)"; a user agent "MUST close the connection to the server and discard the received response". Other implementations follow this: Go's net/http de-duplicates identical values and fails with message cannot contain multiple Content-Length headers otherwise, h11 accepts identical values (including the list form) and raises conflicting Content-Length headers for differing ones, llhttp (Node.js, aiohttp's default parser) rejects any repeated Content-Length, and aiohttp's pure-Python parser rejects it too in strict mode (Duplicate 'Content-Length' header found).

This PR collects the distinct values from every Content-Length field (splitting on commas and stripping OWS, as GH-159156 already does for a single value). A single distinct value is used as before, so 7 + 7 and 7, 7 frame the body as 7 bytes. Several distinct values are handled the way gh-150752 handles a malformed value: the header is treated as invalid, the body is read until the connection closes and will_close is set, so the connection is never reused with unread body bytes on it. No new exception is introduced. The two new tests fail on current main (4 subtests: both orders of 5/7 are framed with the first value, and both list forms are treated as missing) and pass with the fix; test_httplib (156 tests) and test_httpservers (103 tests) pass.

This is a follow-up to gh-150752 and GH-159156 in the same lines of begin(); backports to 3.14/3.15 would need gh-159020 and gh-159021 to land first.

AI assistance (Claude Code) was used to investigate and draft this change; the diff, tests and references were reviewed by the author.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant