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

Reply via email to