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: pFad - Phonifier reborn

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

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


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy