From 4275fa79d94b956f6af096406a44506a552b28f8 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Wed, 27 May 2026 08:53:08 +0800 Subject: [PATCH] =?UTF-8?q?gh-150499:=20http.server:=20enforce=20RFC=20723?= =?UTF-8?q?0=20=C2=A73.3.3=20framing=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two framing checks to BaseHTTPRequestHandler so the handler does not desynchronise on a persistent connection: * RFC 7230 section 3.3.3 rule 3: reject any Transfer-Encoding header with 400 Bad Request and close, because http.server does not implement a chunked decoder. Once a decoder is added this check can be narrowed to anything other than 'identity'. * RFC 7230 section 3.3.3 rule 4: reject duplicate Content-Length headers whose values disagree, with 400 Bad Request and close. Identical values are still accepted. The ยง6.3 keep-alive body-drain piece is split into a follow-up PR. Add regression tests in Lib/test/test_httpservers.py covering the two defects plus a pipelined keep-alive POST regression test. Signed-off-by: tonghuaroot --- Lib/http/server.py | 17 +++ Lib/test/test_httpservers.py | 104 ++++++++++++++++++ ...-05-27-00-43-54.gh-issue-150499.ykruLi.rst | 4 + 3 files changed, 125 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-05-27-00-43-54.gh-issue-150499.ykruLi.rst diff --git a/Lib/http/server.py b/Lib/http/server.py index ebc85052aecb90..408e0100c7364a 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -393,6 +393,23 @@ def parse_request(self): ) return False + # RFC 7230 section 3.3.3 rule 3: this handler does not implement + # a chunked decoder, so any Transfer-Encoding is rejected. + if self.headers.get('Transfer-Encoding'): + self.send_error( + HTTPStatus.BAD_REQUEST, + "Transfer-Encoding not supported") + return False + + # RFC 7230 section 3.3.3 rule 4: reject duplicate Content-Length + # values that disagree. + cl_values = self.headers.get_all('Content-Length') or [] + if len({v.strip() for v in cl_values}) > 1: + self.send_error( + HTTPStatus.BAD_REQUEST, + "Conflicting Content-Length values") + return False + conntype = self.headers.get('Connection', "") if conntype.lower() == 'close': self.close_connection = True diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index d4ae032610a91e..448aab9c4e2ad4 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -364,6 +364,110 @@ def test_head_via_send_error(self): self.assertEqual(b'', data) +class RFC7230FramingTestCase(BaseTestCase): + """Exercise the framing checks added for RFC 7230 section 3.3.3.""" + + class request_handler(NoLogRequestHandler, BaseHTTPRequestHandler): + protocol_version = 'HTTP/1.1' + default_request_version = 'HTTP/1.1' + + def do_POST(self): + cl = self.headers.get('Content-Length') + n = int(cl) if cl and cl.isdigit() else 0 + body = self.rfile.read(n) if n else b'' + out = b'POST body=' + body + b'\n' + self.send_response(HTTPStatus.OK) + self.send_header('Content-Type', 'text/plain') + self.send_header('Content-Length', str(len(out))) + self.end_headers() + self.wfile.write(out) + + def _send_raw(self, payload, timeout=2): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect((self.HOST, self.PORT)) + try: + sock.sendall(payload) + data = b'' + try: + while True: + chunk = sock.recv(4096) + if not chunk: + break + data += chunk + except TimeoutError: + pass + finally: + sock.close() + return data + + def test_transfer_encoding_rejected(self): + # RFC 7230 section 3.3.3 rule 3 plus no chunked decoder. + data = self._send_raw( + b'POST / HTTP/1.1\r\n' + b'Host: 127.0.0.1\r\n' + b'Transfer-Encoding: chunked\r\n' + b'Content-Length: 5\r\n' + b'\r\n' + b'0\r\n\r\nGET /pwn HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n' + ) + self.assertTrue( + data.startswith(b'HTTP/1.0 400') or data.startswith(b'HTTP/1.1 400'), + data[:80]) + self.assertIn(b'Connection: close', data) + self.assertNotIn(b'/pwn', data) + + def test_duplicate_content_length_rejected(self): + # RFC 7230 section 3.3.3 rule 4. + data = self._send_raw( + b'POST / HTTP/1.1\r\n' + b'Host: 127.0.0.1\r\n' + b'Content-Length: 4\r\n' + b'Content-Length: 0\r\n' + b'\r\n' + b'ABCD' + ) + self.assertTrue( + data.startswith(b'HTTP/1.0 400') or data.startswith(b'HTTP/1.1 400'), + data[:80]) + self.assertIn(b'Connection: close', data) + + def test_duplicate_content_length_same_value_accepted(self): + # Two Content-Length headers with the same value are not a conflict + # per RFC 7230 section 3.3.3 rule 4. + data = self._send_raw( + b'POST / HTTP/1.1\r\n' + b'Host: 127.0.0.1\r\n' + b'Content-Length: 4\r\n' + b'Content-Length: 4\r\n' + b'\r\n' + b'ABCD' + ) + self.assertTrue( + data.startswith(b'HTTP/1.0 200') or data.startswith(b'HTTP/1.1 200'), + data[:80]) + self.assertIn(b"POST body=ABCD", data) + + def test_keep_alive_post_pipeline(self): + # Regression: two pipelined POSTs with correct Content-Length + # both succeed on a single keep-alive connection. + data = self._send_raw( + b'POST / HTTP/1.1\r\n' + b'Host: 127.0.0.1\r\n' + b'Content-Length: 4\r\n' + b'\r\n' + b'ABCD' + b'POST / HTTP/1.1\r\n' + b'Host: 127.0.0.1\r\n' + b'Content-Length: 3\r\n' + b'\r\n' + b'XYZ' + ) + self.assertEqual(data.count(b'HTTP/1.1 200'), 2) + self.assertIn(b'POST body=ABCD', data) + self.assertIn(b'POST body=XYZ', data) + + class HTTP09ServerTestCase(BaseTestCase): class request_handler(NoLogRequestHandler, BaseHTTPRequestHandler): diff --git a/Misc/NEWS.d/next/Library/2026-05-27-00-43-54.gh-issue-150499.ykruLi.rst b/Misc/NEWS.d/next/Library/2026-05-27-00-43-54.gh-issue-150499.ykruLi.rst new file mode 100644 index 00000000000000..692fad74d83468 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-05-27-00-43-54.gh-issue-150499.ykruLi.rst @@ -0,0 +1,4 @@ +:mod:`http.server` now rejects requests that send any ``Transfer-Encoding`` +header or that pair conflicting ``Content-Length`` values, since the module +does not implement a chunked decoder. Both rejections return +``400 Bad Request`` with ``Connection: close``, per :rfc:`7230#section-3.3.3`.