https://github.com/python/cpython/commit/37bc3865c87bd777eebd41f1371b2add1bff433d commit: 37bc3865c87bd777eebd41f1371b2add1bff433d branch: main author: Semyon Moroz <donbar...@proton.me> committer: picnixz <10796600+picn...@users.noreply.github.com> date: 2025-04-05T08:49:48Z summary:
gh-85162: Add `HTTPSServer` to `http.server` to serve files over HTTPS (#129607) The `http.server` module now supports serving over HTTPS using the `http.server.HTTPSServer` class. This functionality is also exposed by the command-line interface (`python -m http.server`) through the `--tls-cert`, `--tls-key` and `--tls-password-file` options. files: A Misc/NEWS.d/next/Library/2025-02-02-00-30-09.gh-issue-85162.BNF_aJ.rst M Doc/library/http.server.rst M Doc/whatsnew/3.14.rst M Lib/http/server.py M Lib/test/test_httpservers.py diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 1b00b09bf6da7f..2d064aab6d717d 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -51,9 +51,49 @@ handler. Code to create and run the server looks like this:: .. versionadded:: 3.7 -The :class:`HTTPServer` and :class:`ThreadingHTTPServer` must be given -a *RequestHandlerClass* on instantiation, of which this module -provides three different variants: +.. class:: HTTPSServer(server_address, RequestHandlerClass,\ + bind_and_activate=True, *, certfile, keyfile=None,\ + password=None, alpn_protocols=None) + + Subclass of :class:`HTTPServer` with a wrapped socket using the :mod:`ssl` module. + If the :mod:`ssl` module is not available, instantiating a :class:`!HTTPSServer` + object fails with a :exc:`RuntimeError`. + + The *certfile* argument is the path to the SSL certificate chain file, + and the *keyfile* is the path to file containing the private key. + + A *password* can be specified for files protected and wrapped with PKCS#8, + but beware that this could possibly expose hardcoded passwords in clear. + + .. seealso:: + + See :meth:`ssl.SSLContext.load_cert_chain` for additional + information on the accepted values for *certfile*, *keyfile* + and *password*. + + When specified, the *alpn_protocols* argument must be a sequence of strings + specifying the "Application-Layer Protocol Negotiation" (ALPN) protocols + supported by the server. ALPN allows the server and the client to negotiate + the application protocol during the TLS handshake. + + By default, it is set to ``["http/1.1"]``, meaning the server supports HTTP/1.1. + + .. versionadded:: next + +.. class:: ThreadingHTTPSServer(server_address, RequestHandlerClass,\ + bind_and_activate=True, *, certfile, keyfile=None,\ + password=None, alpn_protocols=None) + + This class is identical to :class:`HTTPSServer` but uses threads to handle + requests by inheriting from :class:`~socketserver.ThreadingMixIn`. This is + analogous to :class:`ThreadingHTTPServer` only using :class:`HTTPSServer`. + + .. versionadded:: next + + +The :class:`HTTPServer`, :class:`ThreadingHTTPServer`, :class:`HTTPSServer` and +:class:`ThreadingHTTPSServer` must be given a *RequestHandlerClass* on +instantiation, of which this module provides three different variants: .. class:: BaseHTTPRequestHandler(request, client_address, server) @@ -542,6 +582,35 @@ The following options are accepted: are not intended for use by untrusted clients and may be vulnerable to exploitation. Always use within a secure environment. +.. option:: --tls-cert + + Specifies a TLS certificate chain for HTTPS connections:: + + python -m http.server --tls-cert fullchain.pem + + .. versionadded:: next + +.. option:: --tls-key + + Specifies a private key file for HTTPS connections. + + This option requires ``--tls-cert`` to be specified. + + .. versionadded:: next + +.. option:: --tls-password-file + + Specifies the password file for password-protected private keys:: + + python -m http.server \ + --tls-cert cert.pem \ + --tls-key key.pem \ + --tls-password-file password.txt + + This option requires `--tls-cert`` to be specified. + + .. versionadded:: next + .. _http.server-security: diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index 81c7969e8af245..13448d1fc07654 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -728,6 +728,17 @@ http module allow the browser to apply its default dark mode. (Contributed by Yorik Hansen in :gh:`123430`.) +* The :mod:`http.server` module now supports serving over HTTPS using the + :class:`http.server.HTTPSServer` class. This functionality is exposed by + the command-line interface (``python -m http.server``) through the following + options: + + * ``--tls-cert <path>``: Path to the TLS certificate file. + * ``--tls-key <path>``: Optional path to the private key file. + * ``--tls-password-file <path>``: Optional path to the password file for the private key. + + (Contributed by Semyon Moroz in :gh:`85162`.) + imaplib ------- diff --git a/Lib/http/server.py b/Lib/http/server.py index a90c8d34c394db..8e36d09ba5e363 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -83,8 +83,10 @@ __version__ = "0.6" __all__ = [ - "HTTPServer", "ThreadingHTTPServer", "BaseHTTPRequestHandler", - "SimpleHTTPRequestHandler", "CGIHTTPRequestHandler", + "HTTPServer", "ThreadingHTTPServer", + "HTTPSServer", "ThreadingHTTPSServer", + "BaseHTTPRequestHandler", "SimpleHTTPRequestHandler", + "CGIHTTPRequestHandler", ] import copy @@ -149,6 +151,47 @@ class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer): daemon_threads = True +class HTTPSServer(HTTPServer): + def __init__(self, server_address, RequestHandlerClass, + bind_and_activate=True, *, certfile, keyfile=None, + password=None, alpn_protocols=None): + try: + import ssl + except ImportError: + raise RuntimeError("SSL module is missing; " + "HTTPS support is unavailable") + + self.ssl = ssl + self.certfile = certfile + self.keyfile = keyfile + self.password = password + # Support by default HTTP/1.1 + self.alpn_protocols = ( + ["http/1.1"] if alpn_protocols is None else alpn_protocols + ) + + super().__init__(server_address, + RequestHandlerClass, + bind_and_activate) + + def server_activate(self): + """Wrap the socket in SSLSocket.""" + super().server_activate() + context = self._create_context() + self.socket = context.wrap_socket(self.socket, server_side=True) + + def _create_context(self): + """Create a secure SSL context.""" + context = self.ssl.create_default_context(self.ssl.Purpose.CLIENT_AUTH) + context.load_cert_chain(self.certfile, self.keyfile, self.password) + context.set_alpn_protocols(self.alpn_protocols) + return context + + +class ThreadingHTTPSServer(socketserver.ThreadingMixIn, HTTPSServer): + daemon_threads = True + + class BaseHTTPRequestHandler(socketserver.StreamRequestHandler): """HTTP request handler base class. @@ -1263,7 +1306,8 @@ def _get_best_family(*address): def test(HandlerClass=BaseHTTPRequestHandler, ServerClass=ThreadingHTTPServer, - protocol="HTTP/1.0", port=8000, bind=None): + protocol="HTTP/1.0", port=8000, bind=None, + tls_cert=None, tls_key=None, tls_password=None): """Test the HTTP request handler class. This runs an HTTP server on port 8000 (or the port argument). @@ -1271,12 +1315,20 @@ def test(HandlerClass=BaseHTTPRequestHandler, """ ServerClass.address_family, addr = _get_best_family(bind, port) HandlerClass.protocol_version = protocol - with ServerClass(addr, HandlerClass) as httpd: + + if tls_cert: + server = ThreadingHTTPSServer(addr, HandlerClass, certfile=tls_cert, + keyfile=tls_key, password=tls_password) + else: + server = ServerClass(addr, HandlerClass) + + with server as httpd: host, port = httpd.socket.getsockname()[:2] url_host = f'[{host}]' if ':' in host else host + protocol = 'HTTPS' if tls_cert else 'HTTP' print( - f"Serving HTTP on {host} port {port} " - f"(http://{url_host}:{port}/) ..." + f"Serving {protocol} on {host} port {port} " + f"({protocol.lower()}://{url_host}:{port}/) ..." ) try: httpd.serve_forever() @@ -1301,10 +1353,31 @@ def test(HandlerClass=BaseHTTPRequestHandler, default='HTTP/1.0', help='conform to this HTTP version ' '(default: %(default)s)') + parser.add_argument('--tls-cert', metavar='PATH', + help='path to the TLS certificate chain file') + parser.add_argument('--tls-key', metavar='PATH', + help='path to the TLS key file') + parser.add_argument('--tls-password-file', metavar='PATH', + help='path to the password file for the TLS key') parser.add_argument('port', default=8000, type=int, nargs='?', help='bind to this port ' '(default: %(default)s)') args = parser.parse_args() + + if not args.tls_cert and args.tls_key: + parser.error("--tls-key requires --tls-cert to be set") + + tls_key_password = None + if args.tls_password_file: + if not args.tls_cert: + parser.error("--tls-password-file requires --tls-cert to be set") + + try: + with open(args.tls_password_file, "r", encoding="utf-8") as f: + tls_key_password = f.read().strip() + except OSError as e: + parser.error(f"Failed to read TLS password file: {e}") + if args.cgi: handler_class = CGIHTTPRequestHandler else: @@ -1330,4 +1403,7 @@ def finish_request(self, request, client_address): port=args.port, bind=args.bind, protocol=args.protocol, + tls_cert=args.tls_cert, + tls_key=args.tls_key, + tls_password=tls_key_password, ) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 1c370dcafa9fea..cb1a8d801692f2 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -4,7 +4,7 @@ Josip Dzolonga, and Michael Otteneder for the 2007/08 GHOP contest. """ from collections import OrderedDict -from http.server import BaseHTTPRequestHandler, HTTPServer, \ +from http.server import BaseHTTPRequestHandler, HTTPServer, HTTPSServer, \ SimpleHTTPRequestHandler, CGIHTTPRequestHandler from http import server, HTTPStatus @@ -31,9 +31,14 @@ import unittest from test import support from test.support import ( - is_apple, os_helper, requires_subprocess, threading_helper + is_apple, import_helper, os_helper, requires_subprocess, threading_helper ) +try: + import ssl +except ImportError: + ssl = None + support.requires_working_socket(module=True) class NoLogRequestHandler: @@ -45,14 +50,49 @@ def read(self, n=None): return '' +class DummyRequestHandler(NoLogRequestHandler, SimpleHTTPRequestHandler): + pass + + +def create_https_server( + certfile, + keyfile=None, + password=None, + *, + address=('localhost', 0), + request_handler=DummyRequestHandler, +): + return HTTPSServer( + address, request_handler, + certfile=certfile, keyfile=keyfile, password=password + ) + + +class TestSSLDisabled(unittest.TestCase): + def test_https_server_raises_runtime_error(self): + with import_helper.isolated_modules(): + sys.modules['ssl'] = None + certfile = certdata_file("keycert.pem") + with self.assertRaises(RuntimeError): + create_https_server(certfile) + + class TestServerThread(threading.Thread): - def __init__(self, test_object, request_handler): + def __init__(self, test_object, request_handler, tls=None): threading.Thread.__init__(self) self.request_handler = request_handler self.test_object = test_object + self.tls = tls def run(self): - self.server = HTTPServer(('localhost', 0), self.request_handler) + if self.tls: + certfile, keyfile, password = self.tls + self.server = create_https_server( + certfile, keyfile, password, + request_handler=self.request_handler, + ) + else: + self.server = HTTPServer(('localhost', 0), self.request_handler) self.test_object.HOST, self.test_object.PORT = self.server.socket.getsockname() self.test_object.server_started.set() self.test_object = None @@ -67,11 +107,15 @@ def stop(self): class BaseTestCase(unittest.TestCase): + + # Optional tuple (certfile, keyfile, password) to use for HTTPS servers. + tls = None + def setUp(self): self._threads = threading_helper.threading_setup() os.environ = os_helper.EnvironmentVarGuard() self.server_started = threading.Event() - self.thread = TestServerThread(self, self.request_handler) + self.thread = TestServerThread(self, self.request_handler, self.tls) self.thread.start() self.server_started.wait() @@ -315,6 +359,74 @@ def test_head_via_send_error(self): self.assertEqual(b'', data) +def certdata_file(*path): + return os.path.join(os.path.dirname(__file__), "certdata", *path) + + +@unittest.skipIf(ssl is None, "requires ssl") +class BaseHTTPSServerTestCase(BaseTestCase): + CERTFILE = certdata_file("keycert.pem") + ONLYCERT = certdata_file("ssl_cert.pem") + ONLYKEY = certdata_file("ssl_key.pem") + CERTFILE_PROTECTED = certdata_file("keycert.passwd.pem") + ONLYKEY_PROTECTED = certdata_file("ssl_key.passwd.pem") + EMPTYCERT = certdata_file("nullcert.pem") + BADCERT = certdata_file("badcert.pem") + KEY_PASSWORD = "somepass" + BADPASSWORD = "badpass" + + tls = (ONLYCERT, ONLYKEY, None) # values by default + + request_handler = DummyRequestHandler + + def test_get(self): + response = self.request('/') + self.assertEqual(response.status, HTTPStatus.OK) + + def request(self, uri, method='GET', body=None, headers={}): + context = ssl._create_unverified_context() + self.connection = http.client.HTTPSConnection( + self.HOST, self.PORT, context=context + ) + self.connection.request(method, uri, body, headers) + return self.connection.getresponse() + + def test_valid_certdata(self): + valid_certdata= [ + (self.CERTFILE, None, None), + (self.CERTFILE, self.CERTFILE, None), + (self.CERTFILE_PROTECTED, None, self.KEY_PASSWORD), + (self.ONLYCERT, self.ONLYKEY_PROTECTED, self.KEY_PASSWORD), + ] + for certfile, keyfile, password in valid_certdata: + with self.subTest( + certfile=certfile, keyfile=keyfile, password=password + ): + server = create_https_server(certfile, keyfile, password) + self.assertIsInstance(server, HTTPSServer) + server.server_close() + + def test_invalid_certdata(self): + invalid_certdata = [ + (self.BADCERT, None, None), + (self.EMPTYCERT, None, None), + (self.ONLYCERT, None, None), + (self.ONLYKEY, None, None), + (self.ONLYKEY, self.ONLYCERT, None), + (self.CERTFILE_PROTECTED, None, self.BADPASSWORD), + # TODO: test the next case and add same case to test_ssl (We + # specify a cert and a password-protected file, but no password): + # (self.CERTFILE_PROTECTED, None, None), + # see issue #132102 + ] + for certfile, keyfile, password in invalid_certdata: + with self.subTest( + certfile=certfile, keyfile=keyfile, password=password + ): + with self.assertRaises(ssl.SSLError): + create_https_server(certfile, keyfile, password) + + class RequestHandlerLoggingTestCase(BaseTestCase): class request_handler(BaseHTTPRequestHandler): protocol_version = 'HTTP/1.1' diff --git a/Misc/NEWS.d/next/Library/2025-02-02-00-30-09.gh-issue-85162.BNF_aJ.rst b/Misc/NEWS.d/next/Library/2025-02-02-00-30-09.gh-issue-85162.BNF_aJ.rst new file mode 100644 index 00000000000000..74646abc684532 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-02-02-00-30-09.gh-issue-85162.BNF_aJ.rst @@ -0,0 +1,5 @@ +The :mod:`http.server` module now includes built-in support for HTTPS +servers exposed by :class:`http.server.HTTPSServer`. This functionality +is exposed by the command-line interface (``python -m http.server``) through +the ``--tls-cert``, ``--tls-key`` and ``--tls-password-file`` options. +Patch by Semyon Moroz. _______________________________________________ Python-checkins mailing list -- python-checkins@python.org To unsubscribe send an email to python-checkins-le...@python.org https://mail.python.org/mailman3/lists/python-checkins.python.org/ Member address: arch...@mail-archive.com