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.
This commit is contained in:
Semyon Moroz 2025-04-05 12:49:48 +04:00 committed by GitHub
parent 99e9798d61
commit 37bc3865c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 287 additions and 14 deletions

View file

@ -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:

View file

@ -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
-------

View file

@ -83,8 +83,10 @@ XXX To do:
__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 @@ if __name__ == '__main__':
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 @@ if __name__ == '__main__':
port=args.port,
bind=args.bind,
protocol=args.protocol,
tls_cert=args.tls_cert,
tls_key=args.tls_key,
tls_password=tls_key_password,
)

View file

@ -4,7 +4,7 @@ Written by Cody A.W. Somerville <cody-somerville@ubuntu.com>,
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 @@ from io import BytesIO, StringIO
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 @@ class NoLogRequestHandler:
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 @@ class TestServerThread(threading.Thread):
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 @@ class BaseHTTPServerTestCase(BaseTestCase):
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'

View file

@ -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.