From 5bfc939700b2bd79fd9e6dba32f9fe788507ad0b Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sun, 2 Feb 2025 16:47:58 +0400 Subject: [PATCH 01/50] Add support HTTPS in http.server --- Lib/http/server.py | 62 +++++++++++++++++-- ...5-02-02-00-30-09.gh-issue-85162.BNF_aJ.rst | 5 ++ 2 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-02-02-00-30-09.gh-issue-85162.BNF_aJ.rst diff --git a/Lib/http/server.py b/Lib/http/server.py index a90c8d34c394db..f5e4d74a170037 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -84,7 +84,8 @@ __all__ = [ "HTTPServer", "ThreadingHTTPServer", "BaseHTTPRequestHandler", - "SimpleHTTPRequestHandler", "CGIHTTPRequestHandler", + "SimpleHTTPRequestHandler", "CGIHTTPRequestHandler", "HTTPSServer", + "ThreadingHTTPSServer", ] import copy @@ -105,6 +106,11 @@ import time import urllib.parse +try: + import ssl +except ImportError: + ssl = None + from http import HTTPStatus @@ -1251,6 +1257,33 @@ def run_cgi(self): self.log_message("CGI script exited OK") +class HTTPSServer(HTTPServer): + def __init__(self, server_address, RequestHandlerClass, + bind_and_activate=True, *, certfile, keyfile): + if ssl is None: + raise ImportError("SSL support missing") + if not certfile: + raise TypeError("__init__() missing required argument 'certfile'") + + self.certfile = certfile + self.keyfile = keyfile + super().__init__(server_address, RequestHandlerClass, bind_and_activate) + + def server_activate(self): + """Wrap the socket in SSLSocket.""" + if ssl is None: + raise ImportError("SSL support missing") + + super().server_activate() + context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile) + self.socket = context.wrap_socket(self.socket, server_side=True) + + +class ThreadingHTTPSServer(socketserver.ThreadingMixIn, HTTPSServer): + daemon_threads = True + + def _get_best_family(*address): infos = socket.getaddrinfo( *address, @@ -1263,7 +1296,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): """Test the HTTP request handler class. This runs an HTTP server on port 8000 (or the port argument). @@ -1271,12 +1305,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 not tls_cert: + server = ServerClass(addr, HandlerClass) + else: + server = ThreadingHTTPSServer(addr, HandlerClass, + certfile=tls_cert, keyfile=tls_key) + + 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 +1343,18 @@ 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='specify the path to a TLS certificate') + parser.add_argument('--tls-key', metavar='PATH', + help='specify the path to a 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') + if args.cgi: handler_class = CGIHTTPRequestHandler else: @@ -1330,4 +1380,6 @@ 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, ) 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..5a50ac2aedcc91 --- /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 +server. New :class:`http.server.HTTPSServer` class is an implementation of +HTTPS server that uses :mod:`ssl` module by providing a certificate and +private key. The ``--tls-cert`` and ``--tls-key`` arguments have been added +to ``python -m http.server``. Patch by Semyon Moroz. From b382985fcc0d6206ee7545ab1c1d06200104d1b3 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sun, 2 Feb 2025 16:51:03 +0400 Subject: [PATCH 02/50] Correct style code --- Lib/http/server.py | 462 +++++++++++++++++++++++---------------------- 1 file changed, 232 insertions(+), 230 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index f5e4d74a170037..b40009f7736b3b 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -138,9 +138,9 @@ DEFAULT_ERROR_CONTENT_TYPE = "text/html;charset=utf-8" -class HTTPServer(socketserver.TCPServer): - allow_reuse_address = True # Seems to make sense in testing environment +class HTTPServer(socketserver.TCPServer): + allow_reuse_address = True # Seems to make sense in testing environment allow_reuse_port = True def server_bind(self): @@ -156,7 +156,6 @@ class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer): class BaseHTTPRequestHandler(socketserver.StreamRequestHandler): - """HTTP request handler base class. The following explanation of HTTP serves to guide you through the @@ -290,8 +289,8 @@ def parse_request(self): self.command = None # set in case of error on the first line self.request_version = version = self.default_request_version self.close_connection = True - requestline = str(self.raw_requestline, 'iso-8859-1') - requestline = requestline.rstrip('\r\n') + requestline = str(self.raw_requestline, "iso-8859-1") + requestline = requestline.rstrip("\r\n") self.requestline = requestline words = requestline.split() if len(words) == 0: @@ -300,9 +299,9 @@ def parse_request(self): if len(words) >= 3: # Enough to determine protocol version version = words[-1] try: - if not version.startswith('HTTP/'): + if not version.startswith("HTTP/"): raise ValueError - base_version_number = version.split('/', 1)[1] + base_version_number = version.split("/", 1)[1] version_number = base_version_number.split(".") # RFC 2145 section 3.1 says there can be only one "." and # - major and minor numbers MUST be treated as @@ -319,30 +318,31 @@ def parse_request(self): version_number = int(version_number[0]), int(version_number[1]) except (ValueError, IndexError): self.send_error( - HTTPStatus.BAD_REQUEST, - "Bad request version (%r)" % version) + HTTPStatus.BAD_REQUEST, "Bad request version (%r)" % version + ) return False if version_number >= (1, 1) and self.protocol_version >= "HTTP/1.1": self.close_connection = False if version_number >= (2, 0): self.send_error( HTTPStatus.HTTP_VERSION_NOT_SUPPORTED, - "Invalid HTTP version (%s)" % base_version_number) + "Invalid HTTP version (%s)" % base_version_number, + ) return False self.request_version = version if not 2 <= len(words) <= 3: self.send_error( - HTTPStatus.BAD_REQUEST, - "Bad request syntax (%r)" % requestline) + HTTPStatus.BAD_REQUEST, "Bad request syntax (%r)" % requestline + ) return False command, path = words[:2] if len(words) == 2: self.close_connection = True - if command != 'GET': + if command != "GET": self.send_error( - HTTPStatus.BAD_REQUEST, - "Bad HTTP/0.9 request type (%r)" % command) + HTTPStatus.BAD_REQUEST, "Bad HTTP/0.9 request type (%r)" % command + ) return False self.command, self.path = command, path @@ -350,8 +350,8 @@ def parse_request(self): # against open redirect attacks possibly triggered if the path starts # with '//' because http clients treat //path as an absolute URI # without scheme (similar to http://path) rather than a path. - if self.path.startswith('//'): - self.path = '/' + self.path.lstrip('/') # Reduce to a single / + if self.path.startswith("//"): + self.path = "/" + self.path.lstrip("/") # Reduce to a single / # Examine the headers and look for a Connection directive. try: @@ -359,29 +359,27 @@ def parse_request(self): _class=self.MessageClass) except http.client.LineTooLong as err: self.send_error( - HTTPStatus.REQUEST_HEADER_FIELDS_TOO_LARGE, - "Line too long", - str(err)) + HTTPStatus.REQUEST_HEADER_FIELDS_TOO_LARGE, "Line too long", str(err) + ) return False except http.client.HTTPException as err: self.send_error( - HTTPStatus.REQUEST_HEADER_FIELDS_TOO_LARGE, - "Too many headers", - str(err) + HTTPStatus.REQUEST_HEADER_FIELDS_TOO_LARGE, "Too many headers", str(err) ) return False - conntype = self.headers.get('Connection', "") - if conntype.lower() == 'close': + conntype = self.headers.get("Connection", "") + if conntype.lower() == "close": self.close_connection = True - elif (conntype.lower() == 'keep-alive' and - self.protocol_version >= "HTTP/1.1"): + elif conntype.lower() == "keep-alive" and self.protocol_version >= "HTTP/1.1": self.close_connection = False # Examine the headers and look for an Expect directive - expect = self.headers.get('Expect', "") - if (expect.lower() == "100-continue" and - self.protocol_version >= "HTTP/1.1" and - self.request_version >= "HTTP/1.1"): + expect = self.headers.get("Expect", "") + if ( + expect.lower() == "100-continue" + and self.protocol_version >= "HTTP/1.1" + and self.request_version >= "HTTP/1.1" + ): if not self.handle_expect_100(): return False return True @@ -415,9 +413,9 @@ def handle_one_request(self): try: self.raw_requestline = self.rfile.readline(65537) if len(self.raw_requestline) > 65536: - self.requestline = '' - self.request_version = '' - self.command = '' + self.requestline = "" + self.request_version = "" + self.command = "" self.send_error(HTTPStatus.REQUEST_URI_TOO_LONG) return if not self.raw_requestline: @@ -426,17 +424,17 @@ def handle_one_request(self): if not self.parse_request(): # An error code has been sent, just exit return - mname = 'do_' + self.command + mname = "do_" + self.command if not hasattr(self, mname): self.send_error( - HTTPStatus.NOT_IMPLEMENTED, - "Unsupported method (%r)" % self.command) + HTTPStatus.NOT_IMPLEMENTED, "Unsupported method (%r)" % self.command + ) return method = getattr(self, mname) method() - self.wfile.flush() #actually send the response if not already done. + self.wfile.flush() # actually send the response if not already done. except TimeoutError as e: - #a read or a write timed out. Discard this connection + # a read or a write timed out. Discard this connection self.log_error("Request timed out: %r", e) self.close_connection = True return @@ -470,14 +468,14 @@ def send_error(self, code, message=None, explain=None): try: shortmsg, longmsg = self.responses[code] except KeyError: - shortmsg, longmsg = '???', '???' + shortmsg, longmsg = "???", "???" if message is None: message = shortmsg if explain is None: explain = longmsg self.log_error("code %d, message %s", code, message) self.send_response(code, message) - self.send_header('Connection', 'close') + self.send_header("Connection", "close") # Message body is omitted for cases described in: # - RFC7230: 3.3. 1xx, 204(No Content), 304(Not Modified) @@ -489,17 +487,17 @@ def send_error(self, code, message=None, explain=None): HTTPStatus.NOT_MODIFIED)): # HTML encode to prevent Cross Site Scripting attacks # (see bug #1100201) - content = (self.error_message_format % { - 'code': code, - 'message': html.escape(message, quote=False), - 'explain': html.escape(explain, quote=False) - }) - body = content.encode('UTF-8', 'replace') + content = self.error_message_format % { + "code": code, + "message": html.escape(message, quote=False), + "explain": html.escape(explain, quote=False), + } + body = content.encode("UTF-8", "replace") self.send_header("Content-Type", self.error_content_type) - self.send_header('Content-Length', str(len(body))) + self.send_header("Content-Length", str(len(body))) self.end_headers() - if self.command != 'HEAD' and body: + if self.command != "HEAD" and body: self.wfile.write(body) def send_response(self, code, message=None): @@ -512,49 +510,52 @@ def send_response(self, code, message=None): """ self.log_request(code) self.send_response_only(code, message) - self.send_header('Server', self.version_string()) - self.send_header('Date', self.date_time_string()) + self.send_header("Server", self.version_string()) + self.send_header("Date", self.date_time_string()) def send_response_only(self, code, message=None): """Send the response header only.""" - if self.request_version != 'HTTP/0.9': + if self.request_version != "HTTP/0.9": if message is None: if code in self.responses: message = self.responses[code][0] else: - message = '' - if not hasattr(self, '_headers_buffer'): + message = "" + if not hasattr(self, "_headers_buffer"): self._headers_buffer = [] - self._headers_buffer.append(("%s %d %s\r\n" % - (self.protocol_version, code, message)).encode( - 'latin-1', 'strict')) + self._headers_buffer.append( + ("%s %d %s\r\n" % (self.protocol_version, code, message)).encode( + "latin-1", "strict" + ) + ) def send_header(self, keyword, value): """Send a MIME header to the headers buffer.""" - if self.request_version != 'HTTP/0.9': - if not hasattr(self, '_headers_buffer'): + if self.request_version != "HTTP/0.9": + if not hasattr(self, "_headers_buffer"): self._headers_buffer = [] self._headers_buffer.append( - ("%s: %s\r\n" % (keyword, value)).encode('latin-1', 'strict')) + ("%s: %s\r\n" % (keyword, value)).encode("latin-1", "strict") + ) - if keyword.lower() == 'connection': - if value.lower() == 'close': + if keyword.lower() == "connection": + if value.lower() == "close": self.close_connection = True - elif value.lower() == 'keep-alive': + elif value.lower() == "keep-alive": self.close_connection = False def end_headers(self): """Send the blank line ending the MIME headers.""" - if self.request_version != 'HTTP/0.9': + if self.request_version != "HTTP/0.9": self._headers_buffer.append(b"\r\n") self.flush_headers() def flush_headers(self): - if hasattr(self, '_headers_buffer'): + if hasattr(self, "_headers_buffer"): self.wfile.write(b"".join(self._headers_buffer)) self._headers_buffer = [] - def log_request(self, code='-', size='-'): + def log_request(self, code="-", size="-"): """Log an accepted request. This is called by send_response(). @@ -562,8 +563,7 @@ def log_request(self, code='-', size='-'): """ if isinstance(code, HTTPStatus): code = code.value - self.log_message('"%s" %s %s', - self.requestline, str(code), str(size)) + self.log_message('"%s" %s %s', self.requestline, str(code), str(size)) def log_error(self, format, *args): """Log an error. @@ -581,8 +581,9 @@ def log_error(self, format, *args): # https://en.wikipedia.org/wiki/List_of_Unicode_characters#Control_codes _control_char_table = str.maketrans( - {c: fr'\x{c:02x}' for c in itertools.chain(range(0x20), range(0x7f,0xa0))}) - _control_char_table[ord('\\')] = r'\\' + {c: rf"\x{c:02x}" for c in itertools.chain(range(0x20), range(0x7F, 0xA0))} + ) + _control_char_table[ord("\\")] = r"\\" def log_message(self, format, *args): """Log an arbitrary message. @@ -612,7 +613,7 @@ def log_message(self, format, *args): def version_string(self): """Return the server software version string.""" - return self.server_version + ' ' + self.sys_version + return self.server_version + " " + self.sys_version def date_time_string(self, timestamp=None): """Return the current date and time formatted for a message header.""" @@ -628,11 +629,11 @@ def log_date_time_string(self): day, self.monthname[month], year, hh, mm, ss) return s - weekdayname = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + weekdayname = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] monthname = [None, - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] def address_string(self): """Return the client address.""" @@ -649,14 +650,10 @@ def address_string(self): MessageClass = http.client.HTTPMessage # hack to maintain backwards compatibility - responses = { - v: (v.phrase, v.description) - for v in HTTPStatus.__members__.values() - } + responses = {v: (v.phrase, v.description) for v in HTTPStatus.__members__.values()} class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): - """Simple HTTP request handler with GET and HEAD commands. This serves files from the current directory and any of its @@ -671,10 +668,10 @@ class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): server_version = "SimpleHTTP/" + __version__ index_pages = ("index.html", "index.htm") extensions_map = _encodings_map_default = { - '.gz': 'application/gzip', - '.Z': 'application/octet-stream', - '.bz2': 'application/x-bzip2', - '.xz': 'application/x-xz', + ".gz": "application/gzip", + ".Z": "application/octet-stream", + ".bz2": "application/x-bzip2", + ".xz": "application/x-xz", } def __init__(self, *args, directory=None, **kwargs): @@ -713,11 +710,10 @@ def send_head(self): f = None if os.path.isdir(path): parts = urllib.parse.urlsplit(self.path) - if not parts.path.endswith('/'): + if not parts.path.endswith("/"): # redirect browser - doing basically what apache does self.send_response(HTTPStatus.MOVED_PERMANENTLY) - new_parts = (parts[0], parts[1], parts[2] + '/', - parts[3], parts[4]) + new_parts = (parts[0], parts[1], parts[2] + "/", parts[3], parts[4]) new_url = urllib.parse.urlunsplit(new_parts) self.send_header("Location", new_url) self.send_header("Content-Length", "0") @@ -740,7 +736,7 @@ def send_head(self): self.send_error(HTTPStatus.NOT_FOUND, "File not found") return None try: - f = open(path, 'rb') + f = open(path, "rb") except OSError: self.send_error(HTTPStatus.NOT_FOUND, "File not found") return None @@ -748,12 +744,15 @@ def send_head(self): try: fs = os.fstat(f.fileno()) # Use browser cache if possible - if ("If-Modified-Since" in self.headers - and "If-None-Match" not in self.headers): + if ( + "If-Modified-Since" in self.headers + and "If-None-Match" not in self.headers + ): # compare If-Modified-Since and time of last file modification try: ims = email.utils.parsedate_to_datetime( - self.headers["If-Modified-Since"]) + self.headers["If-Modified-Since"] + ) except (TypeError, IndexError, OverflowError, ValueError): # ignore ill-formed values pass @@ -778,8 +777,7 @@ def send_head(self): self.send_response(HTTPStatus.OK) self.send_header("Content-type", ctype) self.send_header("Content-Length", str(fs[6])) - self.send_header("Last-Modified", - self.date_time_string(fs.st_mtime)) + self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) self.end_headers() return f except: @@ -797,28 +795,27 @@ def list_directory(self, path): try: list = os.listdir(path) except OSError: - self.send_error( - HTTPStatus.NOT_FOUND, - "No permission to list directory") + self.send_error(HTTPStatus.NOT_FOUND, "No permission to list directory") return None list.sort(key=lambda a: a.lower()) r = [] try: - displaypath = urllib.parse.unquote(self.path, - errors='surrogatepass') + displaypath = urllib.parse.unquote(self.path, errors="surrogatepass") except UnicodeDecodeError: displaypath = urllib.parse.unquote(self.path) displaypath = html.escape(displaypath, quote=False) enc = sys.getfilesystemencoding() - title = f'Directory listing for {displaypath}' - r.append('') + title = f"Directory listing for {displaypath}" + r.append("") r.append('') - r.append('') + r.append("") r.append(f'') - r.append('') - r.append(f'{title}\n') - r.append(f'\n

{title}

') - r.append('
\n\n
\n\n\n") + encoded = "\n".join(r).encode(enc, "surrogateescape") f = io.BytesIO() f.write(encoded) f.seek(0) @@ -853,16 +853,16 @@ def translate_path(self, path): """ # abandon query parameters - path = path.split('?',1)[0] - path = path.split('#',1)[0] + path = path.split("?", 1)[0] + path = path.split("#", 1)[0] # Don't forget explicit trailing slash when normalizing. Issue17324 - trailing_slash = path.rstrip().endswith('/') + trailing_slash = path.rstrip().endswith("/") try: - path = urllib.parse.unquote(path, errors='surrogatepass') + path = urllib.parse.unquote(path, errors="surrogatepass") except UnicodeDecodeError: path = urllib.parse.unquote(path) path = posixpath.normpath(path) - words = path.split('/') + words = path.split("/") words = filter(None, words) path = self.directory for word in words: @@ -871,7 +871,7 @@ def translate_path(self, path): continue path = os.path.join(path, word) if trailing_slash: - path += '/' + path += "/" return path def copyfile(self, source, outputfile): @@ -913,11 +913,12 @@ def guess_type(self, path): guess, _ = mimetypes.guess_file_type(path) if guess: return guess - return 'application/octet-stream' + return "application/octet-stream" # Utilities for CGIHTTPRequestHandler + def _url_collapse_path(path): """ Given a URL path, remove extra '/'s and '.' path elements and collapse @@ -933,41 +934,41 @@ def _url_collapse_path(path): """ # Query component should not be involved. - path, _, query = path.partition('?') + path, _, query = path.partition("?") path = urllib.parse.unquote(path) # Similar to os.path.split(os.path.normpath(path)) but specific to URL # path semantics rather than local operating system semantics. - path_parts = path.split('/') + path_parts = path.split("/") head_parts = [] for part in path_parts[:-1]: - if part == '..': - head_parts.pop() # IndexError if more '..' than prior parts - elif part and part != '.': - head_parts.append( part ) + if part == "..": + head_parts.pop() # IndexError if more '..' than prior parts + elif part and part != ".": + head_parts.append(part) if path_parts: tail_part = path_parts.pop() if tail_part: - if tail_part == '..': + if tail_part == "..": head_parts.pop() - tail_part = '' - elif tail_part == '.': - tail_part = '' + tail_part = "" + elif tail_part == ".": + tail_part = "" else: - tail_part = '' + tail_part = "" if query: - tail_part = '?'.join((tail_part, query)) + tail_part = "?".join((tail_part, query)) - splitpath = ('/' + '/'.join(head_parts), tail_part) + splitpath = ("/" + "/".join(head_parts), tail_part) collapsed_path = "/".join(splitpath) return collapsed_path - nobody = None + def nobody_uid(): """Internal routine to get nobody's uid""" global nobody @@ -978,7 +979,7 @@ def nobody_uid(): except ImportError: return -1 try: - nobody = pwd.getpwnam('nobody')[2] + nobody = pwd.getpwnam("nobody")[2] except KeyError: nobody = 1 + max(x[2] for x in pwd.getpwall()) return nobody @@ -990,7 +991,6 @@ def executable(path): class CGIHTTPRequestHandler(SimpleHTTPRequestHandler): - """Complete HTTP server with GET, HEAD and POST commands. GET and HEAD also support running CGI scripts. @@ -1001,12 +1001,12 @@ class CGIHTTPRequestHandler(SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): import warnings - warnings._deprecated("http.server.CGIHTTPRequestHandler", - remove=(3, 15)) + + warnings._deprecated("http.server.CGIHTTPRequestHandler", remove=(3, 15)) super().__init__(*args, **kwargs) # Determine platform specifics - have_fork = hasattr(os, 'fork') + have_fork = hasattr(os, "fork") # Make rfile unbuffered -- we need to read one line and then pass # the rest to a subprocess, so we can't use buffered input. @@ -1022,9 +1022,7 @@ def do_POST(self): if self.is_cgi(): self.run_cgi() else: - self.send_error( - HTTPStatus.NOT_IMPLEMENTED, - "Can only POST to CGI scripts") + self.send_error(HTTPStatus.NOT_IMPLEMENTED, "Can only POST to CGI scripts") def send_head(self): """Version of send_head that support CGI scripts""" @@ -1049,17 +1047,16 @@ def is_cgi(self): """ collapsed_path = _url_collapse_path(self.path) - dir_sep = collapsed_path.find('/', 1) + dir_sep = collapsed_path.find("/", 1) while dir_sep > 0 and not collapsed_path[:dir_sep] in self.cgi_directories: - dir_sep = collapsed_path.find('/', dir_sep+1) + dir_sep = collapsed_path.find("/", dir_sep + 1) if dir_sep > 0: - head, tail = collapsed_path[:dir_sep], collapsed_path[dir_sep+1:] + head, tail = collapsed_path[:dir_sep], collapsed_path[dir_sep + 1 :] self.cgi_info = head, tail return True return False - - cgi_directories = ['/cgi-bin', '/htbin'] + cgi_directories = ["/cgi-bin", "/htbin"] def is_executable(self, path): """Test whether argument path is an executable file.""" @@ -1073,121 +1070,124 @@ def is_python(self, path): def run_cgi(self): """Execute a CGI script.""" dir, rest = self.cgi_info - path = dir + '/' + rest - i = path.find('/', len(dir)+1) + path = dir + "/" + rest + i = path.find("/", len(dir) + 1) while i >= 0: nextdir = path[:i] - nextrest = path[i+1:] + nextrest = path[i + 1 :] scriptdir = self.translate_path(nextdir) if os.path.isdir(scriptdir): dir, rest = nextdir, nextrest - i = path.find('/', len(dir)+1) + i = path.find("/", len(dir) + 1) else: break # find an explicit query string, if present. - rest, _, query = rest.partition('?') + rest, _, query = rest.partition("?") # dissect the part after the directory name into a script name & # a possible additional path, to be stored in PATH_INFO. - i = rest.find('/') + i = rest.find("/") if i >= 0: script, rest = rest[:i], rest[i:] else: - script, rest = rest, '' + script, rest = rest, "" - scriptname = dir + '/' + script + scriptname = dir + "/" + script scriptfile = self.translate_path(scriptname) if not os.path.exists(scriptfile): self.send_error( - HTTPStatus.NOT_FOUND, - "No such CGI script (%r)" % scriptname) + HTTPStatus.NOT_FOUND, "No such CGI script (%r)" % scriptname + ) return if not os.path.isfile(scriptfile): self.send_error( - HTTPStatus.FORBIDDEN, - "CGI script is not a plain file (%r)" % scriptname) + HTTPStatus.FORBIDDEN, "CGI script is not a plain file (%r)" % scriptname + ) return ispy = self.is_python(scriptname) if self.have_fork or not ispy: if not self.is_executable(scriptfile): self.send_error( HTTPStatus.FORBIDDEN, - "CGI script is not executable (%r)" % scriptname) + "CGI script is not executable (%r)" % scriptname, + ) return # Reference: http://hoohoo.ncsa.uiuc.edu/cgi/env.html # XXX Much of the following could be prepared ahead of time! env = copy.deepcopy(os.environ) - env['SERVER_SOFTWARE'] = self.version_string() - env['SERVER_NAME'] = self.server.server_name - env['GATEWAY_INTERFACE'] = 'CGI/1.1' - env['SERVER_PROTOCOL'] = self.protocol_version - env['SERVER_PORT'] = str(self.server.server_port) - env['REQUEST_METHOD'] = self.command + env["SERVER_SOFTWARE"] = self.version_string() + env["SERVER_NAME"] = self.server.server_name + env["GATEWAY_INTERFACE"] = "CGI/1.1" + env["SERVER_PROTOCOL"] = self.protocol_version + env["SERVER_PORT"] = str(self.server.server_port) + env["REQUEST_METHOD"] = self.command uqrest = urllib.parse.unquote(rest) - env['PATH_INFO'] = uqrest - env['PATH_TRANSLATED'] = self.translate_path(uqrest) - env['SCRIPT_NAME'] = scriptname - env['QUERY_STRING'] = query - env['REMOTE_ADDR'] = self.client_address[0] + env["PATH_INFO"] = uqrest + env["PATH_TRANSLATED"] = self.translate_path(uqrest) + env["SCRIPT_NAME"] = scriptname + env["QUERY_STRING"] = query + env["REMOTE_ADDR"] = self.client_address[0] authorization = self.headers.get("authorization") if authorization: authorization = authorization.split() if len(authorization) == 2: import base64, binascii - env['AUTH_TYPE'] = authorization[0] + + env["AUTH_TYPE"] = authorization[0] if authorization[0].lower() == "basic": try: - authorization = authorization[1].encode('ascii') - authorization = base64.decodebytes(authorization).\ - decode('ascii') + authorization = authorization[1].encode("ascii") + authorization = base64.decodebytes(authorization).decode( + "ascii" + ) except (binascii.Error, UnicodeError): pass else: - authorization = authorization.split(':') + authorization = authorization.split(":") if len(authorization) == 2: - env['REMOTE_USER'] = authorization[0] + env["REMOTE_USER"] = authorization[0] # XXX REMOTE_IDENT - if self.headers.get('content-type') is None: - env['CONTENT_TYPE'] = self.headers.get_content_type() + if self.headers.get("content-type") is None: + env["CONTENT_TYPE"] = self.headers.get_content_type() else: - env['CONTENT_TYPE'] = self.headers['content-type'] - length = self.headers.get('content-length') + env["CONTENT_TYPE"] = self.headers["content-type"] + length = self.headers.get("content-length") if length: - env['CONTENT_LENGTH'] = length - referer = self.headers.get('referer') + env["CONTENT_LENGTH"] = length + referer = self.headers.get("referer") if referer: - env['HTTP_REFERER'] = referer - accept = self.headers.get_all('accept', ()) - env['HTTP_ACCEPT'] = ','.join(accept) - ua = self.headers.get('user-agent') + env["HTTP_REFERER"] = referer + accept = self.headers.get_all("accept", ()) + env["HTTP_ACCEPT"] = ",".join(accept) + ua = self.headers.get("user-agent") if ua: - env['HTTP_USER_AGENT'] = ua - co = filter(None, self.headers.get_all('cookie', [])) - cookie_str = ', '.join(co) + env["HTTP_USER_AGENT"] = ua + co = filter(None, self.headers.get_all("cookie", [])) + cookie_str = ", ".join(co) if cookie_str: - env['HTTP_COOKIE'] = cookie_str + env["HTTP_COOKIE"] = cookie_str # XXX Other HTTP_* headers # Since we're setting the env in the parent, provide empty # values to override previously set values - for k in ('QUERY_STRING', 'REMOTE_HOST', 'CONTENT_LENGTH', - 'HTTP_USER_AGENT', 'HTTP_COOKIE', 'HTTP_REFERER'): + for k in ("QUERY_STRING", "REMOTE_HOST", "CONTENT_LENGTH", + "HTTP_USER_AGENT", "HTTP_COOKIE", "HTTP_REFERER"): env.setdefault(k, "") self.send_response(HTTPStatus.OK, "Script output follows") self.flush_headers() - decoded_query = query.replace('+', ' ') + decoded_query = query.replace("+", " ") if self.have_fork: # Unix -- fork as we should args = [script] - if '=' not in decoded_query: + if "=" not in decoded_query: args.append(decoded_query) nobody = nobody_uid() - self.wfile.flush() # Always flush before forking + self.wfile.flush() # Always flush before forking pid = os.fork() if pid != 0: # Parent @@ -1216,26 +1216,28 @@ def run_cgi(self): else: # Non-Unix -- use subprocess import subprocess + cmdline = [scriptfile] if self.is_python(scriptfile): interp = sys.executable if interp.lower().endswith("w.exe"): # On Windows, use python.exe, not pythonw.exe interp = interp[:-5] + interp[-4:] - cmdline = [interp, '-u'] + cmdline - if '=' not in query: + cmdline = [interp, "-u"] + cmdline + if "=" not in query: cmdline.append(query) self.log_message("command: %s", subprocess.list2cmdline(cmdline)) try: nbytes = int(length) except (TypeError, ValueError): nbytes = 0 - p = subprocess.Popen(cmdline, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env = env - ) + p = subprocess.Popen( + cmdline, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) if self.command.lower() == "post" and nbytes > 0: data = self.rfile.read(nbytes) else: @@ -1247,7 +1249,7 @@ def run_cgi(self): stdout, stderr = p.communicate(data) self.wfile.write(stdout) if stderr: - self.log_error('%s', stderr) + self.log_error("%s", stderr) p.stderr.close() p.stdout.close() status = p.returncode @@ -1309,13 +1311,14 @@ def test(HandlerClass=BaseHTTPRequestHandler, if not tls_cert: server = ServerClass(addr, HandlerClass) else: - server = ThreadingHTTPSServer(addr, HandlerClass, - certfile=tls_cert, keyfile=tls_key) + server = ThreadingHTTPSServer( + addr, HandlerClass, certfile=tls_cert, keyfile=tls_key + ) 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' + url_host = f"[{host}]" if ":" in host else host + protocol = "HTTPS" if tls_cert else "HTTP" print( f"Serving {protocol} on {host} port {port} " f"({protocol.lower()}://{url_host}:{port}/) ..." @@ -1326,34 +1329,35 @@ def test(HandlerClass=BaseHTTPRequestHandler, print("\nKeyboard interrupt received, exiting.") sys.exit(0) -if __name__ == '__main__': + +if __name__ == "__main__": import argparse import contextlib parser = argparse.ArgumentParser() - parser.add_argument('--cgi', action='store_true', - help='run as CGI server') - parser.add_argument('-b', '--bind', metavar='ADDRESS', - help='bind to this address ' - '(default: all interfaces)') - parser.add_argument('-d', '--directory', default=os.getcwd(), - help='serve this directory ' - '(default: current directory)') - parser.add_argument('-p', '--protocol', metavar='VERSION', - default='HTTP/1.0', - help='conform to this HTTP version ' - '(default: %(default)s)') - parser.add_argument('--tls-cert', metavar='PATH', - help='specify the path to a TLS certificate') - parser.add_argument('--tls-key', metavar='PATH', - help='specify the path to a TLS key') - parser.add_argument('port', default=8000, type=int, nargs='?', - help='bind to this port ' - '(default: %(default)s)') + parser.add_argument("--cgi", action="store_true", + help="Run as CGI server") + parser.add_argument("-b", "--bind", metavar="ADDRESS", + help="Bind to this address " + "(default: all interfaces)") + parser.add_argument("-d", "--directory", default=os.getcwd(), + help="Serve this directory " + "(default: current directory)") + parser.add_argument("-p", "--protocol", metavar="VERSION", + default="HTTP/1.0", + help="Conform to this HTTP version " + "(default: %(default)s)") + parser.add_argument("--tls-cert", metavar="PATH", + help="Specify the path to a TLS certificate") + parser.add_argument("--tls-key", metavar="PATH", + help="Specify the path to a 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') + parser.error("--tls-key requires --tls-cert to be set") if args.cgi: handler_class = CGIHTTPRequestHandler @@ -1362,12 +1366,10 @@ def test(HandlerClass=BaseHTTPRequestHandler, # ensure dual-stack is not disabled; ref #38907 class DualStackServer(ThreadingHTTPServer): - def server_bind(self): # suppress exception when protocol is IPv4 with contextlib.suppress(Exception): - self.socket.setsockopt( - socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) return super().server_bind() def finish_request(self, request, client_address): From 4cc80c5309f29422e2cbd9f144d2cb6527660174 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sun, 2 Feb 2025 22:56:49 +0400 Subject: [PATCH 03/50] Add tests for HTTPSServer --- Lib/test/test_httpservers.py | 48 +++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 1c370dcafa9fea..451376ccb70f0d 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 @@ -34,6 +34,11 @@ is_apple, os_helper, requires_subprocess, threading_helper ) +try: + import ssl +except ImportError: + ssl = None + support.requires_working_socket(module=True) class NoLogRequestHandler: @@ -46,13 +51,22 @@ def read(self, n=None): 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: + self.server = HTTPSServer( + ('localhost', 0), + self.request_handler, + certfile=self.tls[0], + keyfile=self.tls[1], + ) + 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 +81,13 @@ def stop(self): class BaseTestCase(unittest.TestCase): + 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 +331,30 @@ def test_head_via_send_error(self): self.assertEqual(b'', data) +@unittest.skipIf(ssl is None, 'No ssl module') +class BaseHTTPSServerTestCase(BaseTestCase): + tls = ( + os.path.join(os.path.dirname(__file__), "certdata", "ssl_cert.pem"), + os.path.join(os.path.dirname(__file__), "certdata", "ssl_key.pem"), + ) + + class request_handler(NoLogRequestHandler, SimpleHTTPRequestHandler): + pass + + def test_get(self): + response = self.request('/') + self.assertEqual(response.status, HTTPStatus.OK) + + def request(self, uri, method='GET', body=None, headers={}): + self.connection = http.client.HTTPSConnection( + self.HOST, + self.PORT, + context=ssl._create_unverified_context() + ) + self.connection.request(method, uri, body, headers) + return self.connection.getresponse() + + class RequestHandlerLoggingTestCase(BaseTestCase): class request_handler(BaseHTTPRequestHandler): protocol_version = 'HTTP/1.1' From 75fff2ba60aedccc6130a1b52f2dd42edbd61230 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Mon, 3 Feb 2025 04:09:27 +0400 Subject: [PATCH 04/50] Update options --- Lib/http/server.py | 47 +++++++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index b40009f7736b3b..cf0ad74a367887 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -111,6 +111,7 @@ except ImportError: ssl = None +from getpass import getpass from http import HTTPStatus @@ -1261,7 +1262,8 @@ def run_cgi(self): class HTTPSServer(HTTPServer): def __init__(self, server_address, RequestHandlerClass, - bind_and_activate=True, *, certfile, keyfile): + bind_and_activate=True, *, certfile, keyfile=None, + password=None, alpn_protocols=None): if ssl is None: raise ImportError("SSL support missing") if not certfile: @@ -1269,6 +1271,10 @@ def __init__(self, server_address, RequestHandlerClass, self.certfile = certfile self.keyfile = keyfile + self.password = password + # Support by default HTTP/1.1 + self.alpn_protocols = alpn_protocols or ["http/1.1"] + super().__init__(server_address, RequestHandlerClass, bind_and_activate) def server_activate(self): @@ -1277,8 +1283,12 @@ def server_activate(self): raise ImportError("SSL support missing") super().server_activate() + context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) - context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile) + context.load_cert_chain(certfile=self.certfile, + keyfile=self.keyfile, + password=self.password) + context.set_alpn_protocols(self.alpn_protocols) self.socket = context.wrap_socket(self.socket, server_side=True) @@ -1299,7 +1309,7 @@ def _get_best_family(*address): def test(HandlerClass=BaseHTTPRequestHandler, ServerClass=ThreadingHTTPServer, protocol="HTTP/1.0", port=8000, bind=None, - tls_cert=None, tls_key=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). @@ -1311,9 +1321,8 @@ def test(HandlerClass=BaseHTTPRequestHandler, if not tls_cert: server = ServerClass(addr, HandlerClass) else: - server = ThreadingHTTPSServer( - addr, HandlerClass, certfile=tls_cert, keyfile=tls_key - ) + server = ThreadingHTTPSServer(addr, HandlerClass, certfile=tls_cert, + keyfile=tls_key, password=tls_password) with server as httpd: host, port = httpd.socket.getsockname()[:2] @@ -1334,31 +1343,42 @@ def test(HandlerClass=BaseHTTPRequestHandler, import argparse import contextlib + PASSWORD_EMPTY = object() + parser = argparse.ArgumentParser() parser.add_argument("--cgi", action="store_true", - help="Run as CGI server") + help="run as CGI server") parser.add_argument("-b", "--bind", metavar="ADDRESS", - help="Bind to this address " + help="bind to this address " "(default: all interfaces)") parser.add_argument("-d", "--directory", default=os.getcwd(), - help="Serve this directory " + help="serve this directory " "(default: current directory)") parser.add_argument("-p", "--protocol", metavar="VERSION", default="HTTP/1.0", - help="Conform to this HTTP version " + help="conform to this HTTP version " "(default: %(default)s)") parser.add_argument("--tls-cert", metavar="PATH", - help="Specify the path to a TLS certificate") + help="path to the TLS certificate") parser.add_argument("--tls-key", metavar="PATH", - help="Specify the path to a TLS key") + help="path to the TLS key") + parser.add_argument("--tls-password", metavar="PASSWORD", nargs="?", + default=None, const=PASSWORD_EMPTY, + help="password for the TLS key " + "(default: empty)") parser.add_argument("port", default=8000, type=int, nargs="?", - help="Bind to this port " + 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") + if not args.tls_key and args.tls_password: + parser.error("--tls-password requires --tls-key to be set") + elif args.tls_password is PASSWORD_EMPTY: + args.tls_password = getpass("Enter the password for the TLS key: ") + if args.cgi: handler_class = CGIHTTPRequestHandler else: @@ -1384,4 +1404,5 @@ def finish_request(self, request, client_address): protocol=args.protocol, tls_cert=args.tls_cert, tls_key=args.tls_key, + tls_password=args.tls_password, ) From 64c307013bacd131f04863faac1b9e30c316e54c Mon Sep 17 00:00:00 2001 From: donBarbos Date: Mon, 3 Feb 2025 13:06:15 +0400 Subject: [PATCH 05/50] Update docs --- Doc/library/http.server.rst | 35 +++++++++++++++++++ ...5-02-02-00-30-09.gh-issue-85162.BNF_aJ.rst | 5 +-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 1197b575c00455..2a47d3ac63b2fc 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -55,6 +55,30 @@ 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) + + This class is a :class:`HTTPServer` subclass with a wrapped socket using the + :mod:`ssl`, if the :mod:`ssl` module is not available the class will not + initialize. The *certfile* argument is required and is the path to the SSL + certificate chain file. The *keyfile* is the path to its private key. But + private keys are often protected and wrapped with PKCS #8, so we provide + *password* argument for that case. + + .. versionadded:: 3.14 + +.. 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 using the :class:`~socketserver.ThreadingMixIn`. This is + analogue of :class:`ThreadingHTTPServer` class only using + :class:`HTTPSServer`. + + .. versionadded:: 3.14 + .. class:: BaseHTTPRequestHandler(request, client_address, server) This class is used to handle the HTTP requests that arrive at the server. By @@ -462,6 +486,17 @@ following command runs an HTTP/1.1 conformant server:: .. versionchanged:: 3.11 Added the ``--protocol`` option. +The server can also support TLS encryption. The options ``--tls-cert`` and +``--tls-key`` allow specifying a TLS certificate chain and private key for +secure HTTPS connections. And ``--tls-password`` option has been added to +``http.server`` to support password-protected private keys. For example, the +following command runs the server with TLS enabled:: + + python -m http.server --tls-cert cert.pem --tls-key key.pem + +.. versionchanged:: 3.14 + Added the ``--tls-cert``, ``--tls-key`` and ``--tls-password`` options. + .. class:: CGIHTTPRequestHandler(request, client_address, server) This class is used to serve either files or output of CGI scripts from the 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 index 5a50ac2aedcc91..4e2bdcb5458f18 100644 --- 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 @@ -1,5 +1,6 @@ The :mod:`http.server` module now includes built-in support for HTTPS server. New :class:`http.server.HTTPSServer` class is an implementation of HTTPS server that uses :mod:`ssl` module by providing a certificate and -private key. The ``--tls-cert`` and ``--tls-key`` arguments have been added -to ``python -m http.server``. Patch by Semyon Moroz. +private key. The ``--tls-cert``, ``--tls-key`` and ``--tls-password`` +arguments have been added to ``python -m http.server``. Patch by Semyon +Moroz. From abd949c5e668825f6d864360093c7e496e6ede61 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Mon, 3 Feb 2025 18:14:45 +0400 Subject: [PATCH 06/50] Revert "Correct style code" This reverts commit b382985fcc0d6206ee7545ab1c1d06200104d1b3. --- Lib/http/server.py | 463 ++++++++++++++++++++++----------------------- 1 file changed, 231 insertions(+), 232 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index cf0ad74a367887..58ef0c518d14ee 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -139,9 +139,9 @@ DEFAULT_ERROR_CONTENT_TYPE = "text/html;charset=utf-8" - class HTTPServer(socketserver.TCPServer): - allow_reuse_address = True # Seems to make sense in testing environment + + allow_reuse_address = True # Seems to make sense in testing environment allow_reuse_port = True def server_bind(self): @@ -157,6 +157,7 @@ class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer): class BaseHTTPRequestHandler(socketserver.StreamRequestHandler): + """HTTP request handler base class. The following explanation of HTTP serves to guide you through the @@ -290,8 +291,8 @@ def parse_request(self): self.command = None # set in case of error on the first line self.request_version = version = self.default_request_version self.close_connection = True - requestline = str(self.raw_requestline, "iso-8859-1") - requestline = requestline.rstrip("\r\n") + requestline = str(self.raw_requestline, 'iso-8859-1') + requestline = requestline.rstrip('\r\n') self.requestline = requestline words = requestline.split() if len(words) == 0: @@ -300,9 +301,9 @@ def parse_request(self): if len(words) >= 3: # Enough to determine protocol version version = words[-1] try: - if not version.startswith("HTTP/"): + if not version.startswith('HTTP/'): raise ValueError - base_version_number = version.split("/", 1)[1] + base_version_number = version.split('/', 1)[1] version_number = base_version_number.split(".") # RFC 2145 section 3.1 says there can be only one "." and # - major and minor numbers MUST be treated as @@ -319,31 +320,30 @@ def parse_request(self): version_number = int(version_number[0]), int(version_number[1]) except (ValueError, IndexError): self.send_error( - HTTPStatus.BAD_REQUEST, "Bad request version (%r)" % version - ) + HTTPStatus.BAD_REQUEST, + "Bad request version (%r)" % version) return False if version_number >= (1, 1) and self.protocol_version >= "HTTP/1.1": self.close_connection = False if version_number >= (2, 0): self.send_error( HTTPStatus.HTTP_VERSION_NOT_SUPPORTED, - "Invalid HTTP version (%s)" % base_version_number, - ) + "Invalid HTTP version (%s)" % base_version_number) return False self.request_version = version if not 2 <= len(words) <= 3: self.send_error( - HTTPStatus.BAD_REQUEST, "Bad request syntax (%r)" % requestline - ) + HTTPStatus.BAD_REQUEST, + "Bad request syntax (%r)" % requestline) return False command, path = words[:2] if len(words) == 2: self.close_connection = True - if command != "GET": + if command != 'GET': self.send_error( - HTTPStatus.BAD_REQUEST, "Bad HTTP/0.9 request type (%r)" % command - ) + HTTPStatus.BAD_REQUEST, + "Bad HTTP/0.9 request type (%r)" % command) return False self.command, self.path = command, path @@ -351,8 +351,8 @@ def parse_request(self): # against open redirect attacks possibly triggered if the path starts # with '//' because http clients treat //path as an absolute URI # without scheme (similar to http://path) rather than a path. - if self.path.startswith("//"): - self.path = "/" + self.path.lstrip("/") # Reduce to a single / + if self.path.startswith('//'): + self.path = '/' + self.path.lstrip('/') # Reduce to a single / # Examine the headers and look for a Connection directive. try: @@ -360,27 +360,29 @@ def parse_request(self): _class=self.MessageClass) except http.client.LineTooLong as err: self.send_error( - HTTPStatus.REQUEST_HEADER_FIELDS_TOO_LARGE, "Line too long", str(err) - ) + HTTPStatus.REQUEST_HEADER_FIELDS_TOO_LARGE, + "Line too long", + str(err)) return False except http.client.HTTPException as err: self.send_error( - HTTPStatus.REQUEST_HEADER_FIELDS_TOO_LARGE, "Too many headers", str(err) + HTTPStatus.REQUEST_HEADER_FIELDS_TOO_LARGE, + "Too many headers", + str(err) ) return False - conntype = self.headers.get("Connection", "") - if conntype.lower() == "close": + conntype = self.headers.get('Connection', "") + if conntype.lower() == 'close': self.close_connection = True - elif conntype.lower() == "keep-alive" and self.protocol_version >= "HTTP/1.1": + elif (conntype.lower() == 'keep-alive' and + self.protocol_version >= "HTTP/1.1"): self.close_connection = False # Examine the headers and look for an Expect directive - expect = self.headers.get("Expect", "") - if ( - expect.lower() == "100-continue" - and self.protocol_version >= "HTTP/1.1" - and self.request_version >= "HTTP/1.1" - ): + expect = self.headers.get('Expect', "") + if (expect.lower() == "100-continue" and + self.protocol_version >= "HTTP/1.1" and + self.request_version >= "HTTP/1.1"): if not self.handle_expect_100(): return False return True @@ -414,9 +416,9 @@ def handle_one_request(self): try: self.raw_requestline = self.rfile.readline(65537) if len(self.raw_requestline) > 65536: - self.requestline = "" - self.request_version = "" - self.command = "" + self.requestline = '' + self.request_version = '' + self.command = '' self.send_error(HTTPStatus.REQUEST_URI_TOO_LONG) return if not self.raw_requestline: @@ -425,17 +427,17 @@ def handle_one_request(self): if not self.parse_request(): # An error code has been sent, just exit return - mname = "do_" + self.command + mname = 'do_' + self.command if not hasattr(self, mname): self.send_error( - HTTPStatus.NOT_IMPLEMENTED, "Unsupported method (%r)" % self.command - ) + HTTPStatus.NOT_IMPLEMENTED, + "Unsupported method (%r)" % self.command) return method = getattr(self, mname) method() - self.wfile.flush() # actually send the response if not already done. + self.wfile.flush() #actually send the response if not already done. except TimeoutError as e: - # a read or a write timed out. Discard this connection + #a read or a write timed out. Discard this connection self.log_error("Request timed out: %r", e) self.close_connection = True return @@ -469,14 +471,14 @@ def send_error(self, code, message=None, explain=None): try: shortmsg, longmsg = self.responses[code] except KeyError: - shortmsg, longmsg = "???", "???" + shortmsg, longmsg = '???', '???' if message is None: message = shortmsg if explain is None: explain = longmsg self.log_error("code %d, message %s", code, message) self.send_response(code, message) - self.send_header("Connection", "close") + self.send_header('Connection', 'close') # Message body is omitted for cases described in: # - RFC7230: 3.3. 1xx, 204(No Content), 304(Not Modified) @@ -488,17 +490,17 @@ def send_error(self, code, message=None, explain=None): HTTPStatus.NOT_MODIFIED)): # HTML encode to prevent Cross Site Scripting attacks # (see bug #1100201) - content = self.error_message_format % { - "code": code, - "message": html.escape(message, quote=False), - "explain": html.escape(explain, quote=False), - } - body = content.encode("UTF-8", "replace") + content = (self.error_message_format % { + 'code': code, + 'message': html.escape(message, quote=False), + 'explain': html.escape(explain, quote=False) + }) + body = content.encode('UTF-8', 'replace') self.send_header("Content-Type", self.error_content_type) - self.send_header("Content-Length", str(len(body))) + self.send_header('Content-Length', str(len(body))) self.end_headers() - if self.command != "HEAD" and body: + if self.command != 'HEAD' and body: self.wfile.write(body) def send_response(self, code, message=None): @@ -511,52 +513,49 @@ def send_response(self, code, message=None): """ self.log_request(code) self.send_response_only(code, message) - self.send_header("Server", self.version_string()) - self.send_header("Date", self.date_time_string()) + self.send_header('Server', self.version_string()) + self.send_header('Date', self.date_time_string()) def send_response_only(self, code, message=None): """Send the response header only.""" - if self.request_version != "HTTP/0.9": + if self.request_version != 'HTTP/0.9': if message is None: if code in self.responses: message = self.responses[code][0] else: - message = "" - if not hasattr(self, "_headers_buffer"): + message = '' + if not hasattr(self, '_headers_buffer'): self._headers_buffer = [] - self._headers_buffer.append( - ("%s %d %s\r\n" % (self.protocol_version, code, message)).encode( - "latin-1", "strict" - ) - ) + self._headers_buffer.append(("%s %d %s\r\n" % + (self.protocol_version, code, message)).encode( + 'latin-1', 'strict')) def send_header(self, keyword, value): """Send a MIME header to the headers buffer.""" - if self.request_version != "HTTP/0.9": - if not hasattr(self, "_headers_buffer"): + if self.request_version != 'HTTP/0.9': + if not hasattr(self, '_headers_buffer'): self._headers_buffer = [] self._headers_buffer.append( - ("%s: %s\r\n" % (keyword, value)).encode("latin-1", "strict") - ) + ("%s: %s\r\n" % (keyword, value)).encode('latin-1', 'strict')) - if keyword.lower() == "connection": - if value.lower() == "close": + if keyword.lower() == 'connection': + if value.lower() == 'close': self.close_connection = True - elif value.lower() == "keep-alive": + elif value.lower() == 'keep-alive': self.close_connection = False def end_headers(self): """Send the blank line ending the MIME headers.""" - if self.request_version != "HTTP/0.9": + if self.request_version != 'HTTP/0.9': self._headers_buffer.append(b"\r\n") self.flush_headers() def flush_headers(self): - if hasattr(self, "_headers_buffer"): + if hasattr(self, '_headers_buffer'): self.wfile.write(b"".join(self._headers_buffer)) self._headers_buffer = [] - def log_request(self, code="-", size="-"): + def log_request(self, code='-', size='-'): """Log an accepted request. This is called by send_response(). @@ -564,7 +563,8 @@ def log_request(self, code="-", size="-"): """ if isinstance(code, HTTPStatus): code = code.value - self.log_message('"%s" %s %s', self.requestline, str(code), str(size)) + self.log_message('"%s" %s %s', + self.requestline, str(code), str(size)) def log_error(self, format, *args): """Log an error. @@ -582,9 +582,8 @@ def log_error(self, format, *args): # https://en.wikipedia.org/wiki/List_of_Unicode_characters#Control_codes _control_char_table = str.maketrans( - {c: rf"\x{c:02x}" for c in itertools.chain(range(0x20), range(0x7F, 0xA0))} - ) - _control_char_table[ord("\\")] = r"\\" + {c: fr'\x{c:02x}' for c in itertools.chain(range(0x20), range(0x7f,0xa0))}) + _control_char_table[ord('\\')] = r'\\' def log_message(self, format, *args): """Log an arbitrary message. @@ -614,7 +613,7 @@ def log_message(self, format, *args): def version_string(self): """Return the server software version string.""" - return self.server_version + " " + self.sys_version + return self.server_version + ' ' + self.sys_version def date_time_string(self, timestamp=None): """Return the current date and time formatted for a message header.""" @@ -630,11 +629,11 @@ def log_date_time_string(self): day, self.monthname[month], year, hh, mm, ss) return s - weekdayname = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + weekdayname = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] monthname = [None, - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] def address_string(self): """Return the client address.""" @@ -651,10 +650,14 @@ def address_string(self): MessageClass = http.client.HTTPMessage # hack to maintain backwards compatibility - responses = {v: (v.phrase, v.description) for v in HTTPStatus.__members__.values()} + responses = { + v: (v.phrase, v.description) + for v in HTTPStatus.__members__.values() + } class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): + """Simple HTTP request handler with GET and HEAD commands. This serves files from the current directory and any of its @@ -669,10 +672,10 @@ class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): server_version = "SimpleHTTP/" + __version__ index_pages = ("index.html", "index.htm") extensions_map = _encodings_map_default = { - ".gz": "application/gzip", - ".Z": "application/octet-stream", - ".bz2": "application/x-bzip2", - ".xz": "application/x-xz", + '.gz': 'application/gzip', + '.Z': 'application/octet-stream', + '.bz2': 'application/x-bzip2', + '.xz': 'application/x-xz', } def __init__(self, *args, directory=None, **kwargs): @@ -711,10 +714,11 @@ def send_head(self): f = None if os.path.isdir(path): parts = urllib.parse.urlsplit(self.path) - if not parts.path.endswith("/"): + if not parts.path.endswith('/'): # redirect browser - doing basically what apache does self.send_response(HTTPStatus.MOVED_PERMANENTLY) - new_parts = (parts[0], parts[1], parts[2] + "/", parts[3], parts[4]) + new_parts = (parts[0], parts[1], parts[2] + '/', + parts[3], parts[4]) new_url = urllib.parse.urlunsplit(new_parts) self.send_header("Location", new_url) self.send_header("Content-Length", "0") @@ -737,7 +741,7 @@ def send_head(self): self.send_error(HTTPStatus.NOT_FOUND, "File not found") return None try: - f = open(path, "rb") + f = open(path, 'rb') except OSError: self.send_error(HTTPStatus.NOT_FOUND, "File not found") return None @@ -745,15 +749,12 @@ def send_head(self): try: fs = os.fstat(f.fileno()) # Use browser cache if possible - if ( - "If-Modified-Since" in self.headers - and "If-None-Match" not in self.headers - ): + if ("If-Modified-Since" in self.headers + and "If-None-Match" not in self.headers): # compare If-Modified-Since and time of last file modification try: ims = email.utils.parsedate_to_datetime( - self.headers["If-Modified-Since"] - ) + self.headers["If-Modified-Since"]) except (TypeError, IndexError, OverflowError, ValueError): # ignore ill-formed values pass @@ -778,7 +779,8 @@ def send_head(self): self.send_response(HTTPStatus.OK) self.send_header("Content-type", ctype) self.send_header("Content-Length", str(fs[6])) - self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) + self.send_header("Last-Modified", + self.date_time_string(fs.st_mtime)) self.end_headers() return f except: @@ -796,27 +798,28 @@ def list_directory(self, path): try: list = os.listdir(path) except OSError: - self.send_error(HTTPStatus.NOT_FOUND, "No permission to list directory") + self.send_error( + HTTPStatus.NOT_FOUND, + "No permission to list directory") return None list.sort(key=lambda a: a.lower()) r = [] try: - displaypath = urllib.parse.unquote(self.path, errors="surrogatepass") + displaypath = urllib.parse.unquote(self.path, + errors='surrogatepass') except UnicodeDecodeError: displaypath = urllib.parse.unquote(self.path) displaypath = html.escape(displaypath, quote=False) enc = sys.getfilesystemencoding() - title = f"Directory listing for {displaypath}" - r.append("") + title = f'Directory listing for {displaypath}' + r.append('') r.append('') - r.append("") + r.append('') r.append(f'') - r.append( - '' - ) - r.append(f"{title}\n") - r.append(f"\n

{title}

") - r.append("
\n
    ") + r.append('') + r.append(f'{title}\n') + r.append(f'\n

    {title}

    ') + r.append('
    \n
      ') for name in list: fullname = os.path.join(path, name) displayname = linkname = name @@ -827,15 +830,12 @@ def list_directory(self, path): if os.path.islink(fullname): displayname = name + "@" # Note: a link to a directory displays with @ and links with / - r.append( - '
    • %s
    • ' - % ( - urllib.parse.quote(linkname, errors="surrogatepass"), - html.escape(displayname, quote=False), - ) - ) - r.append("
    \n
    \n\n\n") - encoded = "\n".join(r).encode(enc, "surrogateescape") + r.append('
  • %s
  • ' + % (urllib.parse.quote(linkname, + errors='surrogatepass'), + html.escape(displayname, quote=False))) + r.append('
\n
\n\n\n') + encoded = '\n'.join(r).encode(enc, 'surrogateescape') f = io.BytesIO() f.write(encoded) f.seek(0) @@ -854,16 +854,16 @@ def translate_path(self, path): """ # abandon query parameters - path = path.split("?", 1)[0] - path = path.split("#", 1)[0] + path = path.split('?',1)[0] + path = path.split('#',1)[0] # Don't forget explicit trailing slash when normalizing. Issue17324 - trailing_slash = path.rstrip().endswith("/") + trailing_slash = path.rstrip().endswith('/') try: - path = urllib.parse.unquote(path, errors="surrogatepass") + path = urllib.parse.unquote(path, errors='surrogatepass') except UnicodeDecodeError: path = urllib.parse.unquote(path) path = posixpath.normpath(path) - words = path.split("/") + words = path.split('/') words = filter(None, words) path = self.directory for word in words: @@ -872,7 +872,7 @@ def translate_path(self, path): continue path = os.path.join(path, word) if trailing_slash: - path += "/" + path += '/' return path def copyfile(self, source, outputfile): @@ -914,12 +914,11 @@ def guess_type(self, path): guess, _ = mimetypes.guess_file_type(path) if guess: return guess - return "application/octet-stream" + return 'application/octet-stream' # Utilities for CGIHTTPRequestHandler - def _url_collapse_path(path): """ Given a URL path, remove extra '/'s and '.' path elements and collapse @@ -935,40 +934,40 @@ def _url_collapse_path(path): """ # Query component should not be involved. - path, _, query = path.partition("?") + path, _, query = path.partition('?') path = urllib.parse.unquote(path) # Similar to os.path.split(os.path.normpath(path)) but specific to URL # path semantics rather than local operating system semantics. - path_parts = path.split("/") + path_parts = path.split('/') head_parts = [] for part in path_parts[:-1]: - if part == "..": - head_parts.pop() # IndexError if more '..' than prior parts - elif part and part != ".": - head_parts.append(part) + if part == '..': + head_parts.pop() # IndexError if more '..' than prior parts + elif part and part != '.': + head_parts.append( part ) if path_parts: tail_part = path_parts.pop() if tail_part: - if tail_part == "..": + if tail_part == '..': head_parts.pop() - tail_part = "" - elif tail_part == ".": - tail_part = "" + tail_part = '' + elif tail_part == '.': + tail_part = '' else: - tail_part = "" + tail_part = '' if query: - tail_part = "?".join((tail_part, query)) + tail_part = '?'.join((tail_part, query)) - splitpath = ("/" + "/".join(head_parts), tail_part) + splitpath = ('/' + '/'.join(head_parts), tail_part) collapsed_path = "/".join(splitpath) return collapsed_path -nobody = None +nobody = None def nobody_uid(): """Internal routine to get nobody's uid""" @@ -980,7 +979,7 @@ def nobody_uid(): except ImportError: return -1 try: - nobody = pwd.getpwnam("nobody")[2] + nobody = pwd.getpwnam('nobody')[2] except KeyError: nobody = 1 + max(x[2] for x in pwd.getpwall()) return nobody @@ -992,6 +991,7 @@ def executable(path): class CGIHTTPRequestHandler(SimpleHTTPRequestHandler): + """Complete HTTP server with GET, HEAD and POST commands. GET and HEAD also support running CGI scripts. @@ -1002,12 +1002,12 @@ class CGIHTTPRequestHandler(SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): import warnings - - warnings._deprecated("http.server.CGIHTTPRequestHandler", remove=(3, 15)) + warnings._deprecated("http.server.CGIHTTPRequestHandler", + remove=(3, 15)) super().__init__(*args, **kwargs) # Determine platform specifics - have_fork = hasattr(os, "fork") + have_fork = hasattr(os, 'fork') # Make rfile unbuffered -- we need to read one line and then pass # the rest to a subprocess, so we can't use buffered input. @@ -1023,7 +1023,9 @@ def do_POST(self): if self.is_cgi(): self.run_cgi() else: - self.send_error(HTTPStatus.NOT_IMPLEMENTED, "Can only POST to CGI scripts") + self.send_error( + HTTPStatus.NOT_IMPLEMENTED, + "Can only POST to CGI scripts") def send_head(self): """Version of send_head that support CGI scripts""" @@ -1048,16 +1050,17 @@ def is_cgi(self): """ collapsed_path = _url_collapse_path(self.path) - dir_sep = collapsed_path.find("/", 1) + dir_sep = collapsed_path.find('/', 1) while dir_sep > 0 and not collapsed_path[:dir_sep] in self.cgi_directories: - dir_sep = collapsed_path.find("/", dir_sep + 1) + dir_sep = collapsed_path.find('/', dir_sep+1) if dir_sep > 0: - head, tail = collapsed_path[:dir_sep], collapsed_path[dir_sep + 1 :] + head, tail = collapsed_path[:dir_sep], collapsed_path[dir_sep+1:] self.cgi_info = head, tail return True return False - cgi_directories = ["/cgi-bin", "/htbin"] + + cgi_directories = ['/cgi-bin', '/htbin'] def is_executable(self, path): """Test whether argument path is an executable file.""" @@ -1071,124 +1074,121 @@ def is_python(self, path): def run_cgi(self): """Execute a CGI script.""" dir, rest = self.cgi_info - path = dir + "/" + rest - i = path.find("/", len(dir) + 1) + path = dir + '/' + rest + i = path.find('/', len(dir)+1) while i >= 0: nextdir = path[:i] - nextrest = path[i + 1 :] + nextrest = path[i+1:] scriptdir = self.translate_path(nextdir) if os.path.isdir(scriptdir): dir, rest = nextdir, nextrest - i = path.find("/", len(dir) + 1) + i = path.find('/', len(dir)+1) else: break # find an explicit query string, if present. - rest, _, query = rest.partition("?") + rest, _, query = rest.partition('?') # dissect the part after the directory name into a script name & # a possible additional path, to be stored in PATH_INFO. - i = rest.find("/") + i = rest.find('/') if i >= 0: script, rest = rest[:i], rest[i:] else: - script, rest = rest, "" + script, rest = rest, '' - scriptname = dir + "/" + script + scriptname = dir + '/' + script scriptfile = self.translate_path(scriptname) if not os.path.exists(scriptfile): self.send_error( - HTTPStatus.NOT_FOUND, "No such CGI script (%r)" % scriptname - ) + HTTPStatus.NOT_FOUND, + "No such CGI script (%r)" % scriptname) return if not os.path.isfile(scriptfile): self.send_error( - HTTPStatus.FORBIDDEN, "CGI script is not a plain file (%r)" % scriptname - ) + HTTPStatus.FORBIDDEN, + "CGI script is not a plain file (%r)" % scriptname) return ispy = self.is_python(scriptname) if self.have_fork or not ispy: if not self.is_executable(scriptfile): self.send_error( HTTPStatus.FORBIDDEN, - "CGI script is not executable (%r)" % scriptname, - ) + "CGI script is not executable (%r)" % scriptname) return # Reference: http://hoohoo.ncsa.uiuc.edu/cgi/env.html # XXX Much of the following could be prepared ahead of time! env = copy.deepcopy(os.environ) - env["SERVER_SOFTWARE"] = self.version_string() - env["SERVER_NAME"] = self.server.server_name - env["GATEWAY_INTERFACE"] = "CGI/1.1" - env["SERVER_PROTOCOL"] = self.protocol_version - env["SERVER_PORT"] = str(self.server.server_port) - env["REQUEST_METHOD"] = self.command + env['SERVER_SOFTWARE'] = self.version_string() + env['SERVER_NAME'] = self.server.server_name + env['GATEWAY_INTERFACE'] = 'CGI/1.1' + env['SERVER_PROTOCOL'] = self.protocol_version + env['SERVER_PORT'] = str(self.server.server_port) + env['REQUEST_METHOD'] = self.command uqrest = urllib.parse.unquote(rest) - env["PATH_INFO"] = uqrest - env["PATH_TRANSLATED"] = self.translate_path(uqrest) - env["SCRIPT_NAME"] = scriptname - env["QUERY_STRING"] = query - env["REMOTE_ADDR"] = self.client_address[0] + env['PATH_INFO'] = uqrest + env['PATH_TRANSLATED'] = self.translate_path(uqrest) + env['SCRIPT_NAME'] = scriptname + env['QUERY_STRING'] = query + env['REMOTE_ADDR'] = self.client_address[0] authorization = self.headers.get("authorization") if authorization: authorization = authorization.split() if len(authorization) == 2: import base64, binascii - - env["AUTH_TYPE"] = authorization[0] + env['AUTH_TYPE'] = authorization[0] if authorization[0].lower() == "basic": try: - authorization = authorization[1].encode("ascii") - authorization = base64.decodebytes(authorization).decode( - "ascii" - ) + authorization = authorization[1].encode('ascii') + authorization = base64.decodebytes(authorization).\ + decode('ascii') except (binascii.Error, UnicodeError): pass else: - authorization = authorization.split(":") + authorization = authorization.split(':') if len(authorization) == 2: - env["REMOTE_USER"] = authorization[0] + env['REMOTE_USER'] = authorization[0] # XXX REMOTE_IDENT - if self.headers.get("content-type") is None: - env["CONTENT_TYPE"] = self.headers.get_content_type() + if self.headers.get('content-type') is None: + env['CONTENT_TYPE'] = self.headers.get_content_type() else: - env["CONTENT_TYPE"] = self.headers["content-type"] - length = self.headers.get("content-length") + env['CONTENT_TYPE'] = self.headers['content-type'] + length = self.headers.get('content-length') if length: - env["CONTENT_LENGTH"] = length - referer = self.headers.get("referer") + env['CONTENT_LENGTH'] = length + referer = self.headers.get('referer') if referer: - env["HTTP_REFERER"] = referer - accept = self.headers.get_all("accept", ()) - env["HTTP_ACCEPT"] = ",".join(accept) - ua = self.headers.get("user-agent") + env['HTTP_REFERER'] = referer + accept = self.headers.get_all('accept', ()) + env['HTTP_ACCEPT'] = ','.join(accept) + ua = self.headers.get('user-agent') if ua: - env["HTTP_USER_AGENT"] = ua - co = filter(None, self.headers.get_all("cookie", [])) - cookie_str = ", ".join(co) + env['HTTP_USER_AGENT'] = ua + co = filter(None, self.headers.get_all('cookie', [])) + cookie_str = ', '.join(co) if cookie_str: - env["HTTP_COOKIE"] = cookie_str + env['HTTP_COOKIE'] = cookie_str # XXX Other HTTP_* headers # Since we're setting the env in the parent, provide empty # values to override previously set values - for k in ("QUERY_STRING", "REMOTE_HOST", "CONTENT_LENGTH", - "HTTP_USER_AGENT", "HTTP_COOKIE", "HTTP_REFERER"): + for k in ('QUERY_STRING', 'REMOTE_HOST', 'CONTENT_LENGTH', + 'HTTP_USER_AGENT', 'HTTP_COOKIE', 'HTTP_REFERER'): env.setdefault(k, "") self.send_response(HTTPStatus.OK, "Script output follows") self.flush_headers() - decoded_query = query.replace("+", " ") + decoded_query = query.replace('+', ' ') if self.have_fork: # Unix -- fork as we should args = [script] - if "=" not in decoded_query: + if '=' not in decoded_query: args.append(decoded_query) nobody = nobody_uid() - self.wfile.flush() # Always flush before forking + self.wfile.flush() # Always flush before forking pid = os.fork() if pid != 0: # Parent @@ -1217,28 +1217,26 @@ def run_cgi(self): else: # Non-Unix -- use subprocess import subprocess - cmdline = [scriptfile] if self.is_python(scriptfile): interp = sys.executable if interp.lower().endswith("w.exe"): # On Windows, use python.exe, not pythonw.exe interp = interp[:-5] + interp[-4:] - cmdline = [interp, "-u"] + cmdline - if "=" not in query: + cmdline = [interp, '-u'] + cmdline + if '=' not in query: cmdline.append(query) self.log_message("command: %s", subprocess.list2cmdline(cmdline)) try: nbytes = int(length) except (TypeError, ValueError): nbytes = 0 - p = subprocess.Popen( - cmdline, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=env, - ) + p = subprocess.Popen(cmdline, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env = env + ) if self.command.lower() == "post" and nbytes > 0: data = self.rfile.read(nbytes) else: @@ -1250,7 +1248,7 @@ def run_cgi(self): stdout, stderr = p.communicate(data) self.wfile.write(stdout) if stderr: - self.log_error("%s", stderr) + self.log_error('%s', stderr) p.stderr.close() p.stdout.close() status = p.returncode @@ -1326,8 +1324,8 @@ def test(HandlerClass=BaseHTTPRequestHandler, 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" + url_host = f'[{host}]' if ':' in host else host + protocol = 'HTTPS' if tls_cert else 'HTTP' print( f"Serving {protocol} on {host} port {port} " f"({protocol.lower()}://{url_host}:{port}/) ..." @@ -1338,41 +1336,40 @@ def test(HandlerClass=BaseHTTPRequestHandler, print("\nKeyboard interrupt received, exiting.") sys.exit(0) - -if __name__ == "__main__": +if __name__ == '__main__': import argparse import contextlib PASSWORD_EMPTY = object() parser = argparse.ArgumentParser() - parser.add_argument("--cgi", action="store_true", - help="run as CGI server") - parser.add_argument("-b", "--bind", metavar="ADDRESS", - help="bind to this address " - "(default: all interfaces)") - parser.add_argument("-d", "--directory", default=os.getcwd(), - help="serve this directory " - "(default: current directory)") - parser.add_argument("-p", "--protocol", metavar="VERSION", - 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") - parser.add_argument("--tls-key", metavar="PATH", - help="path to the TLS key") - parser.add_argument("--tls-password", metavar="PASSWORD", nargs="?", + parser.add_argument('--cgi', action='store_true', + help='run as CGI server') + parser.add_argument('-b', '--bind', metavar='ADDRESS', + help='bind to this address ' + '(default: all interfaces)') + parser.add_argument('-d', '--directory', default=os.getcwd(), + help='serve this directory ' + '(default: current directory)') + parser.add_argument('-p', '--protocol', metavar='VERSION', + 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') + parser.add_argument('--tls-key', metavar='PATH', + help='path to the TLS key') + parser.add_argument('--tls-password', metavar='PASSWORD', nargs='?', default=None, const=PASSWORD_EMPTY, - help="password for the TLS key " - "(default: empty)") - parser.add_argument("port", default=8000, type=int, nargs="?", - help="bind to this port " - "(default: %(default)s)") + help='password for the TLS key ' + '(default: empty)') + 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") + parser.error('--tls-key requires --tls-cert to be set') if not args.tls_key and args.tls_password: parser.error("--tls-password requires --tls-key to be set") @@ -1386,10 +1383,12 @@ def test(HandlerClass=BaseHTTPRequestHandler, # ensure dual-stack is not disabled; ref #38907 class DualStackServer(ThreadingHTTPServer): + def server_bind(self): # suppress exception when protocol is IPv4 with contextlib.suppress(Exception): - self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + self.socket.setsockopt( + socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) return super().server_bind() def finish_request(self, request, client_address): From 4f587bdb29ecefa8b19e4ee911c8e787e76c1ed4 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Mon, 3 Feb 2025 23:14:26 +0400 Subject: [PATCH 07/50] Update docs and correct raising errors --- Doc/library/http.server.rst | 35 +++++++++++++++++++++++------------ Doc/whatsnew/3.14.rst | 12 ++++++++++++ Lib/http/server.py | 6 ++---- 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 2a47d3ac63b2fc..0a8d7eb1a3f552 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -51,22 +51,28 @@ 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) This class is a :class:`HTTPServer` subclass with a wrapped socket using the :mod:`ssl`, if the :mod:`ssl` module is not available the class will not - initialize. The *certfile* argument is required and is the path to the SSL + initialize. + + The *certfile* argument is required and is the path to the SSL certificate chain file. The *keyfile* is the path to its private key. But private keys are often protected and wrapped with PKCS #8, so we provide *password* argument for that case. - .. versionadded:: 3.14 + The *alpn_protocols* argument, if provided, should be a sequence of strings + specifying the Application-Layer Protocol Negotiation (ALPN) protocols + supported by the server. ALPN allows the server and client to negotiate + the application protocol during the TLS handshake. By default, it is set + to ``["http/1.1"]``, meaning the server will support HTTP/1.1. Other + possible values may include ``["h2", "http/1.1"]`` to enable HTTP/2 + support. + + .. versionadded:: next .. class:: ThreadingHTTPSServer(server_address, RequestHandlerClass, \ bind_and_activate=True, *, certfile, keyfile=None, \ @@ -77,7 +83,12 @@ provides three different variants: analogue of :class:`ThreadingHTTPServer` class only using :class:`HTTPSServer`. - .. versionadded:: 3.14 + .. 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) @@ -488,13 +499,13 @@ following command runs an HTTP/1.1 conformant server:: The server can also support TLS encryption. The options ``--tls-cert`` and ``--tls-key`` allow specifying a TLS certificate chain and private key for -secure HTTPS connections. And ``--tls-password`` option has been added to -``http.server`` to support password-protected private keys. For example, the -following command runs the server with TLS enabled:: +secure HTTPS connections. Use ``--tls-password`` option if private keys are +passphrase-protected. For example, the following command runs the server with +TLS enabled:: - python -m http.server --tls-cert cert.pem --tls-key key.pem + python -m http.server --tls-cert cert.pem --tls-key key.pem --tls-password -.. versionchanged:: 3.14 +.. versionchanged:: next Added the ``--tls-cert``, ``--tls-key`` and ``--tls-password`` options. .. class:: CGIHTTPRequestHandler(request, client_address, server) diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index 59c432d30a342b..852ace03aa347e 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -440,6 +440,18 @@ 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 + new :class:`http.server.HTTPSServer`. This class is a subclass of + :class:`http.server.HTTPServer` that enables TLS encryption with the + :mod:`ssl` module. To use HTTPS from the command line, new options have been + added to ``python -m http.server``: + + * ``--tls-cert ``: Path to the TLS certificate file. + * ``--tls-key ``: Path to the private key file. + * ``--tls-password ``: Optional password for the private key. + + (Contributed by Semyon Moroz in :gh:`85162`.) + inspect ------- diff --git a/Lib/http/server.py b/Lib/http/server.py index 58ef0c518d14ee..1408535953fda2 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -1263,9 +1263,7 @@ def __init__(self, server_address, RequestHandlerClass, bind_and_activate=True, *, certfile, keyfile=None, password=None, alpn_protocols=None): if ssl is None: - raise ImportError("SSL support missing") - if not certfile: - raise TypeError("__init__() missing required argument 'certfile'") + raise RuntimeError("SSL support missing") self.certfile = certfile self.keyfile = keyfile @@ -1278,7 +1276,7 @@ def __init__(self, server_address, RequestHandlerClass, def server_activate(self): """Wrap the socket in SSLSocket.""" if ssl is None: - raise ImportError("SSL support missing") + raise RuntimeError("SSL support missing") super().server_activate() From db796cda8fe4bd778301a62c931dce5141a70917 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Tue, 4 Feb 2025 00:22:45 +0400 Subject: [PATCH 08/50] Add helper method _create_context --- Lib/http/server.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index 1408535953fda2..233aebafa4e311 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -1275,17 +1275,22 @@ def __init__(self, server_address, RequestHandlerClass, 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): if ssl is None: raise RuntimeError("SSL support missing") - super().server_activate() - context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile, password=self.password) context.set_alpn_protocols(self.alpn_protocols) - self.socket = context.wrap_socket(self.socket, server_side=True) + + return context class ThreadingHTTPSServer(socketserver.ThreadingMixIn, HTTPSServer): From 96d4a686fe9fa2c993d66f0a4fbb68aba2285bc3 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Tue, 4 Feb 2025 12:54:31 +0400 Subject: [PATCH 09/50] Update docs and replace password option --- Doc/library/http.server.rst | 14 +++--- Doc/whatsnew/3.14.rst | 4 +- Lib/http/server.py | 45 +++++++++---------- ...5-02-02-00-30-09.gh-issue-85162.BNF_aJ.rst | 2 +- 4 files changed, 32 insertions(+), 33 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 0a8d7eb1a3f552..41218c8b413449 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -68,9 +68,7 @@ handler. Code to create and run the server looks like this:: specifying the Application-Layer Protocol Negotiation (ALPN) protocols supported by the server. ALPN allows the server and client to negotiate the application protocol during the TLS handshake. By default, it is set - to ``["http/1.1"]``, meaning the server will support HTTP/1.1. Other - possible values may include ``["h2", "http/1.1"]`` to enable HTTP/2 - support. + to ``["http/1.1"]``, meaning the server will support HTTP/1.1. .. versionadded:: next @@ -499,14 +497,18 @@ following command runs an HTTP/1.1 conformant server:: The server can also support TLS encryption. The options ``--tls-cert`` and ``--tls-key`` allow specifying a TLS certificate chain and private key for -secure HTTPS connections. Use ``--tls-password`` option if private keys are +secure HTTPS connections. Use ``--tls-password-file`` option if private keys are passphrase-protected. For example, the following command runs the server with TLS enabled:: - python -m http.server --tls-cert cert.pem --tls-key key.pem --tls-password + python -m http.server --tls-cert fullchain.pem + +Or if a separate file with private key passphrase-protected:: + + python -m http.server --tls-cert cert.pem --tls-key key.pem --tls-password-file password.txt .. versionchanged:: next - Added the ``--tls-cert``, ``--tls-key`` and ``--tls-password`` options. + Added the ``--tls-cert``, ``--tls-key`` and ``--tls-password-file`` options. .. class:: CGIHTTPRequestHandler(request, client_address, server) diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index 852ace03aa347e..2d1fb6b493344d 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -447,8 +447,8 @@ http added to ``python -m http.server``: * ``--tls-cert ``: Path to the TLS certificate file. - * ``--tls-key ``: Path to the private key file. - * ``--tls-password ``: Optional password for the private key. + * ``--tls-key ``: Optional path to the private key file. + * ``--tls-password-file ``: Optional path to the password for the private key. (Contributed by Semyon Moroz in :gh:`85162`.) diff --git a/Lib/http/server.py b/Lib/http/server.py index 233aebafa4e311..49e1e9a69bbb43 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -106,12 +106,6 @@ import time import urllib.parse -try: - import ssl -except ImportError: - ssl = None - -from getpass import getpass from http import HTTPStatus @@ -1262,9 +1256,12 @@ class HTTPSServer(HTTPServer): def __init__(self, server_address, RequestHandlerClass, bind_and_activate=True, *, certfile, keyfile=None, password=None, alpn_protocols=None): - if ssl is None: - raise RuntimeError("SSL support missing") + 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 @@ -1281,10 +1278,8 @@ def server_activate(self): self.socket = context.wrap_socket(self.socket, server_side=True) def _create_context(self): - if ssl is None: - raise RuntimeError("SSL support missing") - - context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + """Create a secure SSL context.""" + context = self.ssl.create_default_context(self.ssl.Purpose.CLIENT_AUTH) context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile, password=self.password) @@ -1343,8 +1338,6 @@ def test(HandlerClass=BaseHTTPRequestHandler, import argparse import contextlib - PASSWORD_EMPTY = object() - parser = argparse.ArgumentParser() parser.add_argument('--cgi', action='store_true', help='run as CGI server') @@ -1362,22 +1355,26 @@ def test(HandlerClass=BaseHTTPRequestHandler, help='path to the TLS certificate') parser.add_argument('--tls-key', metavar='PATH', help='path to the TLS key') - parser.add_argument('--tls-password', metavar='PASSWORD', nargs='?', - default=None, const=PASSWORD_EMPTY, - help='password for the TLS key ' - '(default: empty)') + parser.add_argument('--tls-password-file', metavar='PATH', + help='file containing the password 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') + 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") - if not args.tls_key and args.tls_password: - parser.error("--tls-password requires --tls-key to be set") - elif args.tls_password is PASSWORD_EMPTY: - args.tls_password = getpass("Enter the password for the TLS key: ") + try: + with open(args.tls_password_file, "r", encoding="utf-8") as f: + tls_key_password = f.read().strip() + except (OSError, IOError) as e: + parser.error(f"Failed to read TLS password file: {e}") if args.cgi: handler_class = CGIHTTPRequestHandler @@ -1406,5 +1403,5 @@ def finish_request(self, request, client_address): protocol=args.protocol, tls_cert=args.tls_cert, tls_key=args.tls_key, - tls_password=args.tls_password, + tls_password=tls_key_password, ) 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 index 4e2bdcb5458f18..2bed53098c372e 100644 --- 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 @@ -1,6 +1,6 @@ The :mod:`http.server` module now includes built-in support for HTTPS server. New :class:`http.server.HTTPSServer` class is an implementation of HTTPS server that uses :mod:`ssl` module by providing a certificate and -private key. The ``--tls-cert``, ``--tls-key`` and ``--tls-password`` +private key. The ``--tls-cert``, ``--tls-key`` and ``--tls-password-file`` arguments have been added to ``python -m http.server``. Patch by Semyon Moroz. From 947f581c16887ae2d2e2399e919a566216edf2c9 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 13:59:09 +0000 Subject: [PATCH 10/50] Update Lib/http/server.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/http/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index 49e1e9a69bbb43..ea0888f9a94f2b 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -1373,7 +1373,7 @@ def test(HandlerClass=BaseHTTPRequestHandler, try: with open(args.tls_password_file, "r", encoding="utf-8") as f: tls_key_password = f.read().strip() - except (OSError, IOError) as e: + except OSError as e: parser.error(f"Failed to read TLS password file: {e}") if args.cgi: From b8ba151f876265c031ab7d028d5068339c3baa23 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 13:59:32 +0000 Subject: [PATCH 11/50] Update Doc/library/http.server.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Doc/library/http.server.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 41218c8b413449..aae8905c6fa34b 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -55,9 +55,9 @@ handler. Code to create and run the server looks like this:: bind_and_activate=True, *, certfile, keyfile=None, \ password=None, alpn_protocols=None) - This class is a :class:`HTTPServer` subclass with a wrapped socket using the - :mod:`ssl`, if the :mod:`ssl` module is not available the class will not - initialize. + Subclass of :class:`HTTPServer` with a wrapped socket using the :mod:`ssl` module. + If the :mod:`ssl` module is not available, instantiating an :class:`!HTTPSServer` + object fails with an :exc:`ImportError`. The *certfile* argument is required and is the path to the SSL certificate chain file. The *keyfile* is the path to its private key. But From 97e2032854b74a2fb9662c804c7d98d4355ec8c5 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 13:59:44 +0000 Subject: [PATCH 12/50] Update Doc/library/http.server.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Doc/library/http.server.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index aae8905c6fa34b..5813dc47e4f879 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -59,10 +59,11 @@ handler. Code to create and run the server looks like this:: If the :mod:`ssl` module is not available, instantiating an :class:`!HTTPSServer` object fails with an :exc:`ImportError`. - The *certfile* argument is required and is the path to the SSL - certificate chain file. The *keyfile* is the path to its private key. But - private keys are often protected and wrapped with PKCS #8, so we provide - *password* argument for that case. + 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. The *alpn_protocols* argument, if provided, should be a sequence of strings specifying the Application-Layer Protocol Negotiation (ALPN) protocols From bd97fd6e433adfc7be22b79d114d929608fc5d11 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 14:00:01 +0000 Subject: [PATCH 13/50] Update Doc/library/http.server.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Doc/library/http.server.rst | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 5813dc47e4f879..732c43dbb59b85 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -65,11 +65,12 @@ handler. Code to create and run the server looks like this:: A *password* can be specified for files protected and wrapped with PKCS#8, but beware that this could possibly expose hardcoded passwords in clear. - The *alpn_protocols* argument, if provided, should be a sequence of strings - specifying the Application-Layer Protocol Negotiation (ALPN) protocols - supported by the server. ALPN allows the server and client to negotiate - the application protocol during the TLS handshake. By default, it is set - to ``["http/1.1"]``, meaning the server will support HTTP/1.1. + 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 From 15b25813b1421b9dfd374df9fdff4378be8dcf48 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 14:00:19 +0000 Subject: [PATCH 14/50] Update Doc/library/http.server.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Doc/library/http.server.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 732c43dbb59b85..af798660ecc222 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -74,9 +74,9 @@ handler. Code to create and run the server looks like this:: .. versionadded:: next -.. class:: ThreadingHTTPSServer(server_address, RequestHandlerClass, \ - bind_and_activate=True, *, certfile, keyfile=None, \ - password=None, alpn_protocols=None) +.. 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 using the :class:`~socketserver.ThreadingMixIn`. This is From 1951e2254507e275f5ed71881a762a36b17606db Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 14:00:29 +0000 Subject: [PATCH 15/50] Update Doc/library/http.server.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Doc/library/http.server.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index af798660ecc222..1896c940719451 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -51,8 +51,8 @@ handler. Code to create and run the server looks like this:: .. versionadded:: 3.7 -.. class:: HTTPSServer(server_address, RequestHandlerClass, \ - bind_and_activate=True, *, certfile, keyfile=None, \ +.. 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. From b4e1ebaa8f8ac383e9d9809d249892e892fc7666 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 14:00:45 +0000 Subject: [PATCH 16/50] Update Lib/http/server.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/http/server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index ea0888f9a94f2b..fb7b8076ce7cba 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -1314,11 +1314,11 @@ def test(HandlerClass=BaseHTTPRequestHandler, ServerClass.address_family, addr = _get_best_family(bind, port) HandlerClass.protocol_version = protocol - if not tls_cert: - server = ServerClass(addr, HandlerClass) - else: + 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] From 4838ff8236e5b03a068ab8d5e99c1f0719d4815c Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 14:00:56 +0000 Subject: [PATCH 17/50] Update Lib/http/server.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/http/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index fb7b8076ce7cba..81a2cd131e7fbf 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -1352,7 +1352,7 @@ def test(HandlerClass=BaseHTTPRequestHandler, help='conform to this HTTP version ' '(default: %(default)s)') parser.add_argument('--tls-cert', metavar='PATH', - help='path to the TLS certificate') + help='path to the TLS certificate chain file') parser.add_argument('--tls-key', metavar='PATH', help='path to the TLS key') parser.add_argument('--tls-password-file', metavar='PATH', From 3a7821f684308267d935a134ae601f2f6aa70523 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 14:01:05 +0000 Subject: [PATCH 18/50] Update Lib/http/server.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/http/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index 81a2cd131e7fbf..20b5ec3e2c268c 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -1354,7 +1354,7 @@ def test(HandlerClass=BaseHTTPRequestHandler, 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') + help='path to the TLS key file') parser.add_argument('--tls-password-file', metavar='PATH', help='file containing the password for the TLS key') parser.add_argument('port', default=8000, type=int, nargs='?', From 85ee1b5455aa8cd3e6029ff37f3774c2591d2b2f Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 14:01:16 +0000 Subject: [PATCH 19/50] Update Lib/http/server.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/http/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index 20b5ec3e2c268c..6bfe03b0328dbf 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -1356,7 +1356,7 @@ def test(HandlerClass=BaseHTTPRequestHandler, parser.add_argument('--tls-key', metavar='PATH', help='path to the TLS key file') parser.add_argument('--tls-password-file', metavar='PATH', - help='file containing the password for the TLS key') + 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)') From 196e71d7d8b1b8969cb44d0719810d9e9b2cd098 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 14:02:44 +0000 Subject: [PATCH 20/50] Update Doc/library/http.server.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Doc/library/http.server.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 1896c940719451..574e3415da93e5 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -79,9 +79,8 @@ handler. Code to create and run the server looks like this:: password=None, alpn_protocols=None) This class is identical to :class:`HTTPSServer` but uses threads to handle - requests by using the :class:`~socketserver.ThreadingMixIn`. This is - analogue of :class:`ThreadingHTTPServer` class only using - :class:`HTTPSServer`. + requests by inheriting from :class:`~socketserver.ThreadingMixIn`. This is + analogous to :class:`ThreadingHTTPServer` only using :class:`HTTPSServer`. .. versionadded:: next From efd44a4b405a8028ddbbb821f3224cc0cbc3e715 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 14:03:04 +0000 Subject: [PATCH 21/50] Update Doc/library/http.server.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Doc/library/http.server.rst | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 574e3415da93e5..f7b5f3dcf52b4e 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -498,15 +498,21 @@ following command runs an HTTP/1.1 conformant server:: The server can also support TLS encryption. The options ``--tls-cert`` and ``--tls-key`` allow specifying a TLS certificate chain and private key for -secure HTTPS connections. Use ``--tls-password-file`` option if private keys are -passphrase-protected. For example, the following command runs the server with -TLS enabled:: +secure HTTPS connections. For example, the following command runs the server with +TLS enabled: - python -m http.server --tls-cert fullchain.pem +.. code-block:: bash -Or if a separate file with private key passphrase-protected:: + python -m http.server --tls-cert fullchain.pem - python -m http.server --tls-cert cert.pem --tls-key key.pem --tls-password-file password.txt +Use ``--tls-password-file`` option if private keys are password-protected: + +.. code-block:: + + python -m http.server \ + --tls-cert cert.pem \ + --tls-key key.pem \ + --tls-password-file password.txt .. versionchanged:: next Added the ``--tls-cert``, ``--tls-key`` and ``--tls-password-file`` options. From 4b33ecc20931438b909a9a3f250c2776819f8269 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 14:03:23 +0000 Subject: [PATCH 22/50] Update Doc/whatsnew/3.14.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Doc/whatsnew/3.14.rst | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index 2d1fb6b493344d..cc08c5a52b017b 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -441,10 +441,8 @@ http (Contributed by Yorik Hansen in :gh:`123430`.) * The :mod:`http.server` module now supports serving over HTTPS using the - new :class:`http.server.HTTPSServer`. This class is a subclass of - :class:`http.server.HTTPServer` that enables TLS encryption with the - :mod:`ssl` module. To use HTTPS from the command line, new options have been - added to ``python -m http.server``: + new :class:`http.server.HTTPSServer`. Furthermore, the following command-line + options have been added to ``python -m http.server``: * ``--tls-cert ``: Path to the TLS certificate file. * ``--tls-key ``: Optional path to the private key file. From 5fcc947c259460355a93d987556167e5e193652d Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 14:03:41 +0000 Subject: [PATCH 23/50] Update Doc/whatsnew/3.14.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Doc/whatsnew/3.14.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index cc08c5a52b017b..26e4393470e6d1 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -446,7 +446,7 @@ http * ``--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 for the private key. + * ``--tls-password-file ``: Optional path to the password file for the private key. (Contributed by Semyon Moroz in :gh:`85162`.) From 4df61de5f9b75d3a1becb3eed24177122d794957 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 14:04:13 +0000 Subject: [PATCH 24/50] Update Lib/http/server.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/http/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index 6bfe03b0328dbf..25665ba33efa3c 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -1266,7 +1266,7 @@ def __init__(self, server_address, RequestHandlerClass, self.keyfile = keyfile self.password = password # Support by default HTTP/1.1 - self.alpn_protocols = alpn_protocols or ["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) From 08a572097017cebe85d810be66e23006dcf11574 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 18:11:35 +0400 Subject: [PATCH 25/50] Add suggestions --- Doc/library/http.server.rst | 2 +- Lib/http/server.py | 107 ++++++++++++++++++------------------ 2 files changed, 55 insertions(+), 54 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index f7b5f3dcf52b4e..c3402de7b648bd 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -61,7 +61,7 @@ handler. Code to create and run the server looks like this:: 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. diff --git a/Lib/http/server.py b/Lib/http/server.py index 25665ba33efa3c..a6f5ed7da4df3f 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -83,9 +83,10 @@ __version__ = "0.6" __all__ = [ - "HTTPServer", "ThreadingHTTPServer", "BaseHTTPRequestHandler", - "SimpleHTTPRequestHandler", "CGIHTTPRequestHandler", "HTTPSServer", - "ThreadingHTTPSServer", + "HTTPServer", "ThreadingHTTPServer", + "HTTPSServer", "ThreadingHTTPSServer", + "BaseHTTPRequestHandler", "SimpleHTTPRequestHandler", + "CGIHTTPRequestHandler", ] import copy @@ -150,6 +151,56 @@ 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(certfile=self.certfile, + keyfile=self.keyfile, + password=self.password) + context.set_alpn_protocols(self.alpn_protocols) + + return context + + +class ThreadingHTTPSServer(socketserver.ThreadingMixIn, HTTPSServer): + daemon_threads = True + + +def _get_best_family(*address): + infos = socket.getaddrinfo( + *address, + type=socket.SOCK_STREAM, + flags=socket.AI_PASSIVE, + ) + family, type, proto, canonname, sockaddr = next(iter(infos)) + return family, sockaddr + + class BaseHTTPRequestHandler(socketserver.StreamRequestHandler): """HTTP request handler base class. @@ -1252,56 +1303,6 @@ def run_cgi(self): self.log_message("CGI script exited OK") -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(certfile=self.certfile, - keyfile=self.keyfile, - password=self.password) - context.set_alpn_protocols(self.alpn_protocols) - - return context - - -class ThreadingHTTPSServer(socketserver.ThreadingMixIn, HTTPSServer): - daemon_threads = True - - -def _get_best_family(*address): - infos = socket.getaddrinfo( - *address, - type=socket.SOCK_STREAM, - flags=socket.AI_PASSIVE, - ) - family, type, proto, canonname, sockaddr = next(iter(infos)) - return family, sockaddr - - def test(HandlerClass=BaseHTTPRequestHandler, ServerClass=ThreadingHTTPServer, protocol="HTTP/1.0", port=8000, bind=None, From 6cff350fc5eeaabb90806b152bbff6af9401cdbd Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 18:45:09 +0400 Subject: [PATCH 26/50] Update 2025-02-02-00-30-09.gh-issue-85162.BNF_aJ.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- .../2025-02-02-00-30-09.gh-issue-85162.BNF_aJ.rst | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 index 2bed53098c372e..45092782da63a7 100644 --- 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 @@ -1,6 +1,5 @@ The :mod:`http.server` module now includes built-in support for HTTPS -server. New :class:`http.server.HTTPSServer` class is an implementation of -HTTPS server that uses :mod:`ssl` module by providing a certificate and -private key. The ``--tls-cert``, ``--tls-key`` and ``--tls-password-file`` -arguments have been added to ``python -m http.server``. Patch by Semyon -Moroz. +servers exposed by :class:`http.server.HTTPSServer`. In addition, the +``--tls-cert``, ``--tls-key`` and ``--tls-password-file`` command-line +arguments have been added to the ``python -m http.server``. +Patch by Semyon Moroz. From 0b2d50a284aefb42e78e22747da3d69de6af6e72 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 18:45:51 +0400 Subject: [PATCH 27/50] Update http.server.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Doc/library/http.server.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index c3402de7b648bd..8361f9833642b4 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -507,7 +507,7 @@ TLS enabled: Use ``--tls-password-file`` option if private keys are password-protected: -.. code-block:: +.. code-block:: bash python -m http.server \ --tls-cert cert.pem \ From 8a7f316f9e62d8d4d4ef4093f8842674b4f2c458 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 18:50:46 +0400 Subject: [PATCH 28/50] Move function back --- Lib/http/server.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index a6f5ed7da4df3f..73ecba69d64ecf 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -191,16 +191,6 @@ class ThreadingHTTPSServer(socketserver.ThreadingMixIn, HTTPSServer): daemon_threads = True -def _get_best_family(*address): - infos = socket.getaddrinfo( - *address, - type=socket.SOCK_STREAM, - flags=socket.AI_PASSIVE, - ) - family, type, proto, canonname, sockaddr = next(iter(infos)) - return family, sockaddr - - class BaseHTTPRequestHandler(socketserver.StreamRequestHandler): """HTTP request handler base class. @@ -1303,6 +1293,16 @@ def run_cgi(self): self.log_message("CGI script exited OK") +def _get_best_family(*address): + infos = socket.getaddrinfo( + *address, + type=socket.SOCK_STREAM, + flags=socket.AI_PASSIVE, + ) + family, type, proto, canonname, sockaddr = next(iter(infos)) + return family, sockaddr + + def test(HandlerClass=BaseHTTPRequestHandler, ServerClass=ThreadingHTTPServer, protocol="HTTP/1.0", port=8000, bind=None, From e7d9250258732716a44b0f7ef4e7c4c9d6ea3306 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 20:44:01 +0400 Subject: [PATCH 29/50] Add test case for pass certdata --- Lib/test/test_httpservers.py | 55 +++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 451376ccb70f0d..f7ecbeff5f9af0 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -64,6 +64,7 @@ def run(self): self.request_handler, certfile=self.tls[0], keyfile=self.tls[1], + password=self.tls[2], ) else: self.server = HTTPServer(('localhost', 0), self.request_handler) @@ -333,10 +334,19 @@ def test_head_via_send_error(self): @unittest.skipIf(ssl is None, 'No ssl module') class BaseHTTPSServerTestCase(BaseTestCase): - tls = ( - os.path.join(os.path.dirname(__file__), "certdata", "ssl_cert.pem"), - os.path.join(os.path.dirname(__file__), "certdata", "ssl_key.pem"), - ) + def _data_file(*name): + return os.path.join(os.path.dirname(__file__), "certdata", *name) + + CERTFILE = _data_file("keycert.pem") + ONLYCERT = _data_file("ssl_cert.pem") + ONLYKEY = _data_file("ssl_key.pem") + CERTFILE_PROTECTED = _data_file("keycert.passwd.pem") + ONLYKEY_PROTECTED = _data_file("ssl_key.passwd.pem") + KEY_PASSWORD = "somepass" + EMPTYCERT = _data_file("nullcert.pem") + BADCERT = _data_file("badcert.pem") + + tls = (ONLYCERT, ONLYKEY, None) # values by default class request_handler(NoLogRequestHandler, SimpleHTTPRequestHandler): pass @@ -354,6 +364,43 @@ def request(self, uri, method='GET', body=None, headers={}): self.connection.request(method, uri, body, headers) return self.connection.getresponse() + def test_valid_certdata(self): + valid_certdata_examples = ( + (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 data in valid_certdata_examples: + server = HTTPSServer( + ('localhost', 0), + BaseHTTPRequestHandler, + certfile=data[0], + keyfile=data[1], + password=data[2], + ) + self.assertIsInstance(server, HTTPSServer) + server.server_close() + + def test_invalid_certdata(self): + invalid_certdata_examples = ( + (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, "badpass"), + ) + for data in invalid_certdata_examples: + with self.assertRaises(ssl.SSLError): + HTTPSServer( + ('localhost', 0), + self.request_handler, + certfile=data[0], + keyfile=data[1], + password=data[2], + ) + class RequestHandlerLoggingTestCase(BaseTestCase): class request_handler(BaseHTTPRequestHandler): From 1b64e3da659fcb8cd8ec01f9fef49dcce4d40938 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 21:10:31 +0400 Subject: [PATCH 30/50] Update test_httpservers.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/test/test_httpservers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index f7ecbeff5f9af0..99ee7ffe1634b0 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -391,7 +391,7 @@ def test_invalid_certdata(self): (self.ONLYKEY, self.ONLYCERT, None), (self.CERTFILE_PROTECTED, None, "badpass"), ) - for data in invalid_certdata_examples: + for cerfile, keyfile, password in invalid_certdata: with self.assertRaises(ssl.SSLError): HTTPSServer( ('localhost', 0), From c004b7105ca7dcd6c5837489224a90ce70975a0f Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 21:10:44 +0400 Subject: [PATCH 31/50] Update test_httpservers.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/test/test_httpservers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 99ee7ffe1634b0..8b8408562f2c27 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -383,14 +383,14 @@ def test_valid_certdata(self): server.server_close() def test_invalid_certdata(self): - invalid_certdata_examples = ( + 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, "badpass"), - ) + ] for cerfile, keyfile, password in invalid_certdata: with self.assertRaises(ssl.SSLError): HTTPSServer( From b6ba37f2b016ab93e08cb4672d48a2667e34d89a Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 21:11:04 +0400 Subject: [PATCH 32/50] Update test_httpservers.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/test/test_httpservers.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 8b8408562f2c27..585f397e2d74a9 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -365,19 +365,19 @@ def request(self, uri, method='GET', body=None, headers={}): return self.connection.getresponse() def test_valid_certdata(self): - valid_certdata_examples = ( + 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 data in valid_certdata_examples: + ] + for certfile, keyfile, password in valid_certdata: server = HTTPSServer( ('localhost', 0), BaseHTTPRequestHandler, - certfile=data[0], - keyfile=data[1], - password=data[2], + certfile=certfile, + keyfile=keyfile, + password=password, ) self.assertIsInstance(server, HTTPSServer) server.server_close() From bf86a0d27e5529a6bfe4649009e14838e6f09a9f Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 21:11:32 +0400 Subject: [PATCH 33/50] Update test_httpservers.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/test/test_httpservers.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 585f397e2d74a9..855f38e5625638 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -356,10 +356,9 @@ def test_get(self): 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=ssl._create_unverified_context() + self.HOST, self.PORT, context=context ) self.connection.request(method, uri, body, headers) return self.connection.getresponse() From b89f4c41c53de5a540a0e25bea42ba564bfa88aa Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 21:11:43 +0400 Subject: [PATCH 34/50] Update test_httpservers.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/test/test_httpservers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 855f38e5625638..8921c3c8703bd3 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -346,7 +346,7 @@ def _data_file(*name): EMPTYCERT = _data_file("nullcert.pem") BADCERT = _data_file("badcert.pem") - tls = (ONLYCERT, ONLYKEY, None) # values by default + tls = (ONLYCERT, ONLYKEY, None) # values by default class request_handler(NoLogRequestHandler, SimpleHTTPRequestHandler): pass From c6879dec4e791655bfd2854c487315fca4fb0554 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 21:12:17 +0400 Subject: [PATCH 35/50] Update test_httpservers.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/test/test_httpservers.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 8921c3c8703bd3..49f1c822a8e2f9 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -332,19 +332,21 @@ def test_head_via_send_error(self): self.assertEqual(b'', data) -@unittest.skipIf(ssl is None, 'No ssl module') +def certdata_file(*path): + return os.path.join(os.path.dirname(__file__), "certdata", *path) + + +@unittest.skipIf(ssl is None, "requires ssl") class BaseHTTPSServerTestCase(BaseTestCase): - def _data_file(*name): - return os.path.join(os.path.dirname(__file__), "certdata", *name) - - CERTFILE = _data_file("keycert.pem") - ONLYCERT = _data_file("ssl_cert.pem") - ONLYKEY = _data_file("ssl_key.pem") - CERTFILE_PROTECTED = _data_file("keycert.passwd.pem") - ONLYKEY_PROTECTED = _data_file("ssl_key.passwd.pem") + + 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") KEY_PASSWORD = "somepass" - EMPTYCERT = _data_file("nullcert.pem") - BADCERT = _data_file("badcert.pem") + EMPTYCERT = certdata_file("nullcert.pem") + BADCERT = certdata_file("badcert.pem") tls = (ONLYCERT, ONLYKEY, None) # values by default From 1ee542f57abc81cc48fb02a337758aa0d1b3cb05 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 21:18:01 +0400 Subject: [PATCH 36/50] Update test_httpservers.py --- Lib/test/test_httpservers.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 49f1c822a8e2f9..e3329b71a21474 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -344,9 +344,10 @@ class BaseHTTPSServerTestCase(BaseTestCase): ONLYKEY = certdata_file("ssl_key.pem") CERTFILE_PROTECTED = certdata_file("keycert.passwd.pem") ONLYKEY_PROTECTED = certdata_file("ssl_key.passwd.pem") - KEY_PASSWORD = "somepass" EMPTYCERT = certdata_file("nullcert.pem") BADCERT = certdata_file("badcert.pem") + KEY_PASSWORD = "somepass" + BADPASSWORD = "badpass" tls = (ONLYCERT, ONLYKEY, None) # values by default @@ -390,16 +391,16 @@ def test_invalid_certdata(self): (self.ONLYCERT, None, None), (self.ONLYKEY, None, None), (self.ONLYKEY, self.ONLYCERT, None), - (self.CERTFILE_PROTECTED, None, "badpass"), + (self.CERTFILE_PROTECTED, None, self.BADPASSWORD), ] for cerfile, keyfile, password in invalid_certdata: with self.assertRaises(ssl.SSLError): HTTPSServer( ('localhost', 0), self.request_handler, - certfile=data[0], - keyfile=data[1], - password=data[2], + certfile=cerfile, + keyfile=keyfile, + password=password, ) From 0c40dd77a57a6905784607be2dd473e404f686f6 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 21:41:48 +0400 Subject: [PATCH 37/50] Add more suggestions --- Doc/library/http.server.rst | 6 ++--- Lib/test/test_httpservers.py | 46 +++++++++++++++++++++--------------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 8361f9833642b4..3e712cb58cd8d7 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -56,7 +56,7 @@ handler. Code to create and run the server looks like this:: 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 an :class:`!HTTPSServer` + If the :mod:`ssl` module is not available, instantiating a :class:`!HTTPSServer` object fails with an :exc:`ImportError`. The *certfile* argument is the path to the SSL certificate chain file, @@ -497,7 +497,7 @@ following command runs an HTTP/1.1 conformant server:: Added the ``--protocol`` option. The server can also support TLS encryption. The options ``--tls-cert`` and -``--tls-key`` allow specifying a TLS certificate chain and private key for +``--tls-key`` allow specifying a TLS certificate chain and a private key for secure HTTPS connections. For example, the following command runs the server with TLS enabled: @@ -505,7 +505,7 @@ TLS enabled: python -m http.server --tls-cert fullchain.pem -Use ``--tls-password-file`` option if private keys are password-protected: +Use the ``--tls-password-file`` option if private keys are password-protected: .. code-block:: bash diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index e3329b71a21474..5b60f652da4a33 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -82,6 +82,8 @@ def stop(self): class BaseTestCase(unittest.TestCase): + + # Optional tuple (certfile, keyfile, password) to use for HTTPS servers. tls = None def setUp(self): @@ -335,10 +337,26 @@ def test_head_via_send_error(self): def certdata_file(*path): return os.path.join(os.path.dirname(__file__), "certdata", *path) +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 + ) + @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") @@ -374,15 +392,10 @@ def test_valid_certdata(self): (self.ONLYCERT, self.ONLYKEY_PROTECTED, self.KEY_PASSWORD), ] for certfile, keyfile, password in valid_certdata: - server = HTTPSServer( - ('localhost', 0), - BaseHTTPRequestHandler, - certfile=certfile, - keyfile=keyfile, - password=password, - ) - self.assertIsInstance(server, HTTPSServer) - server.server_close() + with self.subTest(certfile=certfile, keyfile=keyfile): + server = create_https_server(certfile, keyfile, password) + self.assertIsInstance(server, HTTPSServer) + server.server_close() def test_invalid_certdata(self): invalid_certdata = [ @@ -393,15 +406,10 @@ def test_invalid_certdata(self): (self.ONLYKEY, self.ONLYCERT, None), (self.CERTFILE_PROTECTED, None, self.BADPASSWORD), ] - for cerfile, keyfile, password in invalid_certdata: - with self.assertRaises(ssl.SSLError): - HTTPSServer( - ('localhost', 0), - self.request_handler, - certfile=cerfile, - keyfile=keyfile, - password=password, - ) + for certfile, keyfile, password in invalid_certdata: + with self.subTest(certfile=certfile, keyfile=keyfile): + with self.assertRaises(ssl.SSLError): + create_https_server(certfile, keyfile, password) class RequestHandlerLoggingTestCase(BaseTestCase): From 6e51ec3b57dfe99ccc789db634e6fd9d2288c3fb Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 21:50:26 +0400 Subject: [PATCH 38/50] Update docs --- Doc/library/http.server.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 3e712cb58cd8d7..af9a976d092eb1 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -57,7 +57,7 @@ handler. Code to create and run the server looks like this:: 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 an :exc:`ImportError`. + object fails with an :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. @@ -498,7 +498,7 @@ following command runs an HTTP/1.1 conformant server:: The server can also support TLS encryption. The options ``--tls-cert`` and ``--tls-key`` allow specifying a TLS certificate chain and a private key for -secure HTTPS connections. For example, the following command runs the server with +HTTPS connections. For example, the following command runs the server with TLS enabled: .. code-block:: bash From 4b852530c063e92532701fa00b31cd1fb8326ee9 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 22:10:53 +0400 Subject: [PATCH 39/50] Update --- Doc/library/http.server.rst | 2 +- Lib/test/test_httpservers.py | 35 ++++++++++++++++++----------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index af9a976d092eb1..8eb085f7e9b8fb 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -57,7 +57,7 @@ handler. Code to create and run the server looks like this:: 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 an :exc:`RuntimeError`. + 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. diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 5b60f652da4a33..03e0506c3b1eba 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -50,6 +50,24 @@ 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 TestServerThread(threading.Thread): def __init__(self, test_object, request_handler, tls=None): threading.Thread.__init__(self) @@ -337,23 +355,6 @@ def test_head_via_send_error(self): def certdata_file(*path): return os.path.join(os.path.dirname(__file__), "certdata", *path) -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 - ) - @unittest.skipIf(ssl is None, "requires ssl") class BaseHTTPSServerTestCase(BaseTestCase): From 09d32b39490340cfd59dbaba2575aa06eab3b9a2 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 22:35:07 +0400 Subject: [PATCH 40/50] Update tests --- Lib/test/test_httpservers.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 03e0506c3b1eba..3587f5f53f4a32 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -31,7 +31,7 @@ import unittest from test import support from test.support import ( - is_apple, os_helper, requires_subprocess, threading_helper + is_apple, os_helper, requires_subprocess, threading_helper, import_helper ) try: @@ -68,6 +68,15 @@ def create_https_server( ) +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, tls=None): threading.Thread.__init__(self) @@ -77,12 +86,10 @@ def __init__(self, test_object, request_handler, tls=None): def run(self): if self.tls: - self.server = HTTPSServer( - ('localhost', 0), - self.request_handler, - certfile=self.tls[0], - keyfile=self.tls[1], - password=self.tls[2], + 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) @@ -393,7 +400,9 @@ def test_valid_certdata(self): (self.ONLYCERT, self.ONLYKEY_PROTECTED, self.KEY_PASSWORD), ] for certfile, keyfile, password in valid_certdata: - with self.subTest(certfile=certfile, keyfile=keyfile): + with self.subTest(certfile=certfile, + keyfile=keyfile, + password=password): server = create_https_server(certfile, keyfile, password) self.assertIsInstance(server, HTTPSServer) server.server_close() @@ -408,7 +417,9 @@ def test_invalid_certdata(self): (self.CERTFILE_PROTECTED, None, self.BADPASSWORD), ] for certfile, keyfile, password in invalid_certdata: - with self.subTest(certfile=certfile, keyfile=keyfile): + with self.subTest(certfile=certfile, + keyfile=keyfile, + password=password): with self.assertRaises(ssl.SSLError): create_https_server(certfile, keyfile, password) From 4b8786f2398d8797975fe06d7c55280dcd9642cb Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 15 Feb 2025 23:03:27 +0400 Subject: [PATCH 41/50] Correct style code --- Lib/http/server.py | 6 +----- Lib/test/test_httpservers.py | 18 +++++++++--------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index 73ecba69d64ecf..ebd526ce09f389 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -172,18 +172,14 @@ def __init__(self, server_address, RequestHandlerClass, 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(certfile=self.certfile, - keyfile=self.keyfile, - password=self.password) + context.load_cert_chain(self.certfile, self.keyfile, self.password) context.set_alpn_protocols(self.alpn_protocols) - return context diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 3587f5f53f4a32..5171fd51bfa784 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -31,7 +31,7 @@ import unittest from test import support from test.support import ( - is_apple, os_helper, requires_subprocess, threading_helper, import_helper + is_apple, import_helper, os_helper, requires_subprocess, threading_helper ) try: @@ -53,6 +53,7 @@ def read(self, n=None): class DummyRequestHandler(NoLogRequestHandler, SimpleHTTPRequestHandler): pass + def create_https_server( certfile, keyfile=None, @@ -377,8 +378,7 @@ class BaseHTTPSServerTestCase(BaseTestCase): tls = (ONLYCERT, ONLYKEY, None) # values by default - class request_handler(NoLogRequestHandler, SimpleHTTPRequestHandler): - pass + request_handler = DummyRequestHandler def test_get(self): response = self.request('/') @@ -400,9 +400,9 @@ def test_valid_certdata(self): (self.ONLYCERT, self.ONLYKEY_PROTECTED, self.KEY_PASSWORD), ] for certfile, keyfile, password in valid_certdata: - with self.subTest(certfile=certfile, - keyfile=keyfile, - password=password): + with self.subTest( + certfile=certfile, keyfile=keyfile, password=password + ): server = create_https_server(certfile, keyfile, password) self.assertIsInstance(server, HTTPSServer) server.server_close() @@ -417,9 +417,9 @@ def test_invalid_certdata(self): (self.CERTFILE_PROTECTED, None, self.BADPASSWORD), ] for certfile, keyfile, password in invalid_certdata: - with self.subTest(certfile=certfile, - keyfile=keyfile, - password=password): + with self.subTest( + certfile=certfile, keyfile=keyfile, password=password + ): with self.assertRaises(ssl.SSLError): create_https_server(certfile, keyfile, password) From 96ba50d116c476ec46d2dc8a9255f5df139aff63 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sun, 16 Feb 2025 00:10:29 +0400 Subject: [PATCH 42/50] Wrap the lines --- Lib/http/server.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index ebd526ce09f389..3788c6968837cf 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -158,14 +158,17 @@ def __init__(self, server_address, RequestHandlerClass, try: import ssl except ImportError: - raise RuntimeError("SSL module is missing; HTTPS support is unavailable") + 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 + self.alpn_protocols = ( + ["http/1.1"] if alpn_protocols is None else alpn_protocols + ) super().__init__(server_address, RequestHandlerClass, bind_and_activate) From 5d87f8072f629a627786c0cac8e4cc83f5b49275 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sun, 16 Feb 2025 00:11:36 +0400 Subject: [PATCH 43/50] Wrap again --- Lib/http/server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index 3788c6968837cf..8e36d09ba5e363 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -170,7 +170,9 @@ def __init__(self, server_address, RequestHandlerClass, ["http/1.1"] if alpn_protocols is None else alpn_protocols ) - super().__init__(server_address, RequestHandlerClass, bind_and_activate) + super().__init__(server_address, + RequestHandlerClass, + bind_and_activate) def server_activate(self): """Wrap the socket in SSLSocket.""" From 05f5f65ed5edcf765d8818b87a03a56e6a26722f Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sun, 16 Feb 2025 01:25:22 +0400 Subject: [PATCH 44/50] Add seealso section --- Doc/library/http.server.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 8eb085f7e9b8fb..f6a03c764595a3 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -65,6 +65,10 @@ handler. Code to create and run the server looks like this:: 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:: You can learn more about how SSL works because the *certfile*, + *keyfile* and *password* parameters are passed to the + :meth:`ssl.SSLContext.load_cert_chain`. + 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 From e7a42f7a680b747538a5068104dfce41744592e7 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sun, 16 Feb 2025 01:33:25 +0400 Subject: [PATCH 45/50] Update http.server.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Doc/library/http.server.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index f6a03c764595a3..65882c5a73fa40 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -65,9 +65,11 @@ handler. Code to create and run the server looks like this:: 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:: You can learn more about how SSL works because the *certfile*, - *keyfile* and *password* parameters are passed to the - :meth:`ssl.SSLContext.load_cert_chain`. + .. 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 From 3ca55d13e63d0c0e4bbf798493f343be977eef46 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sun, 16 Mar 2025 14:59:48 +0400 Subject: [PATCH 46/50] Update cli description --- Doc/library/http.server.rst | 49 +++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 8b578ab0fcd463..70034bb68166db 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -459,27 +459,6 @@ such as using different index file names by overriding the class attribute :attr:`index_pages`. -The server can also support TLS encryption. The options ``--tls-cert`` and -``--tls-key`` allow specifying a TLS certificate chain and a private key for -HTTPS connections. For example, the following command runs the server with -TLS enabled: - -.. code-block:: bash - - python -m http.server --tls-cert fullchain.pem - -Use the ``--tls-password-file`` option if private keys are password-protected: - -.. code-block:: bash - - python -m http.server \ - --tls-cert cert.pem \ - --tls-key key.pem \ - --tls-password-file password.txt - -.. versionchanged:: next - Added the ``--tls-cert``, ``--tls-key`` and ``--tls-password-file`` options. - .. class:: CGIHTTPRequestHandler(request, client_address, server) This class is used to serve either files or output of CGI scripts from the @@ -603,6 +582,34 @@ 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 + + The server can also support TLS encryption. The option ``--tls-cert`` allow + specifying a TLS certificate chain for HTTPS connections. For example, + the following command runs the server with TLS enabled:: + + python -m http.server --tls-cert fullchain.pem + + .. versionadded:: next + +.. option:: --tls-key + + Specifies private key for HTTPS connections. + + .. versionadded:: next + +.. option:: --tls-password-file + + Use the ``--tls-password-file`` option if private keys are + password-protected:: + + python -m http.server \ + --tls-cert cert.pem \ + --tls-key key.pem \ + --tls-password-file password.txt + + .. versionadded:: next + .. _http.server-security: From 3daf484b3a734b73b5176d7711f735d29b8b9029 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sun, 16 Mar 2025 15:49:37 +0400 Subject: [PATCH 47/50] Update doc --- Doc/library/http.server.rst | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 70034bb68166db..2d064aab6d717d 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -584,9 +584,7 @@ The following options are accepted: .. option:: --tls-cert - The server can also support TLS encryption. The option ``--tls-cert`` allow - specifying a TLS certificate chain for HTTPS connections. For example, - the following command runs the server with TLS enabled:: + Specifies a TLS certificate chain for HTTPS connections:: python -m http.server --tls-cert fullchain.pem @@ -594,20 +592,23 @@ The following options are accepted: .. option:: --tls-key - Specifies private key for HTTPS connections. + Specifies a private key file for HTTPS connections. + + This option requires ``--tls-cert`` to be specified. .. versionadded:: next .. option:: --tls-password-file - Use the ``--tls-password-file`` option if private keys are - password-protected:: + 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 From 8b84be2794d56d06b7881016860797c5601ad811 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Sat, 5 Apr 2025 03:30:28 +0400 Subject: [PATCH 48/50] Update docs --- Doc/whatsnew/3.14.rst | 5 +++-- Lib/test/test_httpservers.py | 5 ++++- .../Library/2025-02-02-00-30-09.gh-issue-85162.BNF_aJ.rst | 6 +++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index 9b4854d0eb9d88..dde4fe95c00942 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -600,8 +600,9 @@ http (Contributed by Yorik Hansen in :gh:`123430`.) * The :mod:`http.server` module now supports serving over HTTPS using the - new :class:`http.server.HTTPSServer`. Furthermore, the following command-line - options have been added to ``python -m http.server``: + new :class:`http.server.HTTPSServer`. 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. diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 5171fd51bfa784..cb1a8d801692f2 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -61,7 +61,6 @@ def create_https_server( *, address=('localhost', 0), request_handler=DummyRequestHandler, - ): return HTTPSServer( address, request_handler, @@ -415,6 +414,10 @@ def test_invalid_certdata(self): (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( 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 index 45092782da63a7..74646abc684532 100644 --- 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 @@ -1,5 +1,5 @@ The :mod:`http.server` module now includes built-in support for HTTPS -servers exposed by :class:`http.server.HTTPSServer`. In addition, the -``--tls-cert``, ``--tls-key`` and ``--tls-password-file`` command-line -arguments have been added to the ``python -m http.server``. +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. From 50e0ed5113280cf5ad239eec948aeb4a470bbe3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 5 Apr 2025 10:25:04 +0200 Subject: [PATCH 49/50] Update Doc/whatsnew/3.14.rst --- Doc/whatsnew/3.14.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index dde4fe95c00942..1662fa386cb53b 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -600,7 +600,7 @@ http (Contributed by Yorik Hansen in :gh:`123430`.) * The :mod:`http.server` module now supports serving over HTTPS using the - new :class:`http.server.HTTPSServer`. This functionality is exposed by + new :class:`http.server.HTTPSServer` class. This functionality is exposed by the command-line interface (``python -m http.server``) through the following options: From 4f36fbf566c428ae589014a862f3273e0fbe99ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 5 Apr 2025 10:25:19 +0200 Subject: [PATCH 50/50] Update Doc/whatsnew/3.14.rst --- Doc/whatsnew/3.14.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index 1662fa386cb53b..d58885f1f07256 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -600,7 +600,7 @@ http (Contributed by Yorik Hansen in :gh:`123430`.) * The :mod:`http.server` module now supports serving over HTTPS using the - new :class:`http.server.HTTPSServer` class. This functionality is exposed by + :class:`http.server.HTTPSServer` class. This functionality is exposed by the command-line interface (``python -m http.server``) through the following options: