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 b1337190636529..d58885f1f07256 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -599,6 +599,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 to the TLS certificate file. + * ``--tls-key ``: Optional path to the private key file. + * ``--tls-password-file ``: 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. pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy