diff --git a/Lib/http/server.py b/Lib/http/server.py index ebc85052aecb900..408e0100c7364ae 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 d4ae032610a91e2..448aab9c4e2ad4e 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 000000000000000..692fad74d834685 --- /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`.