diff --git a/Doc/library/http.client.rst b/Doc/library/http.client.rst index 2835c8d0eb711e..07f5ebf57c9b54 100644 --- a/Doc/library/http.client.rst +++ b/Doc/library/http.client.rst @@ -34,7 +34,7 @@ The module provides the following classes: .. class:: HTTPConnection(host, port=None[, timeout], source_address=None, \ - blocksize=8192) + blocksize=8192, max_response_headers=None) An :class:`HTTPConnection` instance represents one transaction with an HTTP server. It should be instantiated by passing it a host and optional port @@ -46,7 +46,9 @@ The module provides the following classes: The optional *source_address* parameter may be a tuple of a (host, port) to use as the source address the HTTP connection is made from. The optional *blocksize* parameter sets the buffer size in bytes for - sending a file-like message body. + sending a file-like message body. The optional *max_response_headers* + parameter sets the maximum number of allowed response headers to help + prevent denial-of-service attacks, otherwise the default value (100) is used. For example, the following calls all create instances that connect to the server at the same host and port:: @@ -66,10 +68,13 @@ The module provides the following classes: .. versionchanged:: 3.7 *blocksize* parameter was added. + .. versionchanged:: next + *max_response_headers* parameter was added. + .. class:: HTTPSConnection(host, port=None, *[, timeout], \ source_address=None, context=None, \ - blocksize=8192) + blocksize=8192, max_response_headers=None) A subclass of :class:`HTTPConnection` that uses SSL for communication with secure servers. Default port is ``443``. If *context* is specified, it @@ -109,6 +114,9 @@ The module provides the following classes: The deprecated *key_file*, *cert_file* and *check_hostname* parameters have been removed. + .. versionchanged:: next + *max_response_headers* parameter was added. + .. class:: HTTPResponse(sock, debuglevel=0, method=None, url=None) @@ -416,6 +424,14 @@ HTTPConnection Objects .. versionadded:: 3.7 +.. attribute:: HTTPConnection.max_response_headers + + The maximum number of allowed response headers to help prevent denial-of-service + attacks. By default, the maximum number of allowed headers is set to 100. + + .. versionadded:: next + + As an alternative to using the :meth:`~HTTPConnection.request` method described above, you can also send your request step by step, by using the four functions below. diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index ea369a36983497..a4993964d6f690 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -230,6 +230,16 @@ difflib (Contributed by Jiahao Li in :gh:`134580`.) +http.client +----------- + +* A new *max_response_headers* keyword-only parameter has been added to + :class:`~http.client.HTTPConnection` and :class:`~http.client.HTTPSConnection` + constructors. This parameter overrides the default maximum number of allowed + response headers. + (Contributed by Alexander Enrique Urieles Nieto in :gh:`131724`.) + + math ---- diff --git a/Lib/http/client.py b/Lib/http/client.py index e7a1c7bc3b2ae1..0cce49cadc09fa 100644 --- a/Lib/http/client.py +++ b/Lib/http/client.py @@ -209,22 +209,24 @@ def getallmatchingheaders(self, name): lst.append(line) return lst -def _read_headers(fp): +def _read_headers(fp, max_headers): """Reads potential header lines into a list from a file pointer. Length of line is limited by _MAXLINE, and number of - headers is limited by _MAXHEADERS. + headers is limited by max_headers. """ headers = [] + if max_headers is None: + max_headers = _MAXHEADERS while True: line = fp.readline(_MAXLINE + 1) if len(line) > _MAXLINE: raise LineTooLong("header line") - headers.append(line) - if len(headers) > _MAXHEADERS: - raise HTTPException("got more than %d headers" % _MAXHEADERS) if line in (b'\r\n', b'\n', b''): break + headers.append(line) + if len(headers) > max_headers: + raise HTTPException(f"got more than {max_headers} headers") return headers def _parse_header_lines(header_lines, _class=HTTPMessage): @@ -241,10 +243,10 @@ def _parse_header_lines(header_lines, _class=HTTPMessage): hstring = b''.join(header_lines).decode('iso-8859-1') return email.parser.Parser(_class=_class).parsestr(hstring) -def parse_headers(fp, _class=HTTPMessage): +def parse_headers(fp, _class=HTTPMessage, *, _max_headers=None): """Parses only RFC2822 headers from a file pointer.""" - headers = _read_headers(fp) + headers = _read_headers(fp, _max_headers) return _parse_header_lines(headers, _class) @@ -320,7 +322,7 @@ def _read_status(self): raise BadStatusLine(line) return version, status, reason - def begin(self): + def begin(self, *, _max_headers=None): if self.headers is not None: # we've already started reading the response return @@ -331,7 +333,7 @@ def begin(self): if status != CONTINUE: break # skip the header from the 100 response - skipped_headers = _read_headers(self.fp) + skipped_headers = _read_headers(self.fp, _max_headers) if self.debuglevel > 0: print("headers:", skipped_headers) del skipped_headers @@ -346,7 +348,9 @@ def begin(self): else: raise UnknownProtocol(version) - self.headers = self.msg = parse_headers(self.fp) + self.headers = self.msg = parse_headers( + self.fp, _max_headers=_max_headers + ) if self.debuglevel > 0: for hdr, val in self.headers.items(): @@ -864,7 +868,7 @@ def _get_content_length(body, method): return None def __init__(self, host, port=None, timeout=socket._GLOBAL_DEFAULT_TIMEOUT, - source_address=None, blocksize=8192): + source_address=None, blocksize=8192, *, max_response_headers=None): self.timeout = timeout self.source_address = source_address self.blocksize = blocksize @@ -877,6 +881,7 @@ def __init__(self, host, port=None, timeout=socket._GLOBAL_DEFAULT_TIMEOUT, self._tunnel_port = None self._tunnel_headers = {} self._raw_proxy_headers = None + self.max_response_headers = max_response_headers (self.host, self.port) = self._get_hostport(host, port) @@ -969,7 +974,7 @@ def _tunnel(self): try: (version, code, message) = response._read_status() - self._raw_proxy_headers = _read_headers(response.fp) + self._raw_proxy_headers = _read_headers(response.fp, self.max_response_headers) if self.debuglevel > 0: for header in self._raw_proxy_headers: @@ -1426,7 +1431,10 @@ def getresponse(self): try: try: - response.begin() + if self.max_response_headers is None: + response.begin() + else: + response.begin(_max_headers=self.max_response_headers) except ConnectionError: self.close() raise @@ -1457,10 +1465,12 @@ class HTTPSConnection(HTTPConnection): def __init__(self, host, port=None, *, timeout=socket._GLOBAL_DEFAULT_TIMEOUT, - source_address=None, context=None, blocksize=8192): + source_address=None, context=None, blocksize=8192, + max_response_headers=None): super(HTTPSConnection, self).__init__(host, port, timeout, source_address, - blocksize=blocksize) + blocksize=blocksize, + max_response_headers=max_response_headers) if context is None: context = _create_https_context(self._http_vsn) self._context = context diff --git a/Lib/test/test_httplib.py b/Lib/test/test_httplib.py index 38429ad480ff1c..47e3914d1dd62e 100644 --- a/Lib/test/test_httplib.py +++ b/Lib/test/test_httplib.py @@ -386,6 +386,52 @@ def test_headers_debuglevel(self): self.assertEqual(lines[2], "header: Second: val1") self.assertEqual(lines[3], "header: Second: val2") + def test_max_response_headers(self): + max_headers = client._MAXHEADERS + 20 + headers = [f"Name{i}: Value{i}".encode() for i in range(max_headers)] + body = b"HTTP/1.1 200 OK\r\n" + b"\r\n".join(headers) + + with self.subTest(max_headers=None): + sock = FakeSocket(body) + resp = client.HTTPResponse(sock) + with self.assertRaisesRegex( + client.HTTPException, f"got more than 100 headers" + ): + resp.begin() + + with self.subTest(max_headers=max_headers): + sock = FakeSocket(body) + resp = client.HTTPResponse(sock) + resp.begin(_max_headers=max_headers) + + def test_max_connection_headers(self): + max_headers = client._MAXHEADERS + 20 + headers = ( + f"Name{i}: Value{i}".encode() for i in range(max_headers - 1) + ) + body = ( + b"HTTP/1.1 200 OK\r\n" + + b"\r\n".join(headers) + + b"\r\nContent-Length: 12\r\n\r\nDummy body\r\n" + ) + + with self.subTest(max_headers=None): + conn = client.HTTPConnection("example.com") + conn.sock = FakeSocket(body) + conn.request("GET", "/") + with self.assertRaisesRegex( + client.HTTPException, f"got more than {client._MAXHEADERS} headers" + ): + response = conn.getresponse() + + with self.subTest(max_headers=None): + conn = client.HTTPConnection( + "example.com", max_response_headers=max_headers + ) + conn.sock = FakeSocket(body) + conn.request("GET", "/") + response = conn.getresponse() + response.read() class HttpMethodTests(TestCase): def test_invalid_method_names(self): diff --git a/Misc/ACKS b/Misc/ACKS index fabd79b9f74210..35826bd713c0f6 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -1954,6 +1954,7 @@ Adnan Umer Utkarsh Upadhyay Roger Upole Daniel Urban +Alexander Enrique Urieles Nieto Matthias Urlichs Michael Urman Hector Urtubia diff --git a/Misc/NEWS.d/next/Library/2025-07-19-15-40-47.gh-issue-131724.LS59nA.rst b/Misc/NEWS.d/next/Library/2025-07-19-15-40-47.gh-issue-131724.LS59nA.rst new file mode 100644 index 00000000000000..71a991aa2c5ae6 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-07-19-15-40-47.gh-issue-131724.LS59nA.rst @@ -0,0 +1,4 @@ +In :mod:`http.client`, a new *max_response_headers* keyword-only parameter has been +added to :class:`~http.client.HTTPConnection` and :class:`~http.client.HTTPSConnection` +constructors. This parameter sets the maximum number of allowed response headers, +helping to prevent denial-of-service attacks.