From 0d02fbe1a3566985f2d17934988a9fe7b62135f4 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 26 May 2025 21:15:56 -0700 Subject: [PATCH 1/3] gh-135056: Add a --cors CLI argument to http.server Add a --cors command line argument to the stdlib http.server module, which will add an `Access-Control-Allow-Origin: *` header to all responses. As part of this implementation, add a `response_headers` argument to SimpleHTTPRequestHandler and HttpServer, which allows callers to add addition headers to the response. --- Doc/library/http.server.rst | 20 ++++++++- Lib/http/server.py | 42 +++++++++++++++---- Lib/test/test_httpservers.py | 32 ++++++++++++-- ...-06-02-22-23-38.gh-issue-135056.yz3dSs.rst | 2 + 4 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 063344e0284258..0f40214b771d2f 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -362,7 +362,7 @@ instantiation, of which this module provides three different variants: delays, it now always returns the IP address. -.. class:: SimpleHTTPRequestHandler(request, client_address, server, directory=None) +.. class:: SimpleHTTPRequestHandler(request, client_address, server, directory=None, response_headers=None) This class serves files from the directory *directory* and below, or the current directory if *directory* is not provided, directly @@ -374,6 +374,10 @@ instantiation, of which this module provides three different variants: .. versionchanged:: 3.9 The *directory* parameter accepts a :term:`path-like object`. + .. versionchanged:: 3.15 + The *response_headers* parameter accepts an optional dictionary of + additional HTTP headers to add to each response. + A lot of the work, such as parsing the request, is done by the base class :class:`BaseHTTPRequestHandler`. This class implements the :func:`do_GET` and :func:`do_HEAD` functions. @@ -428,6 +432,9 @@ instantiation, of which this module provides three different variants: followed by a ``'Content-Length:'`` header with the file's size and a ``'Last-Modified:'`` header with the file's modification time. + The headers specified in the dictionary instance argument + ``response_headers`` are each individually sent in the response. + Then follows a blank line signifying the end of the headers, and then the contents of the file are output. @@ -437,6 +444,9 @@ instantiation, of which this module provides three different variants: .. versionchanged:: 3.7 Support of the ``'If-Modified-Since'`` header. + .. versionchanged:: 3.15 + Support ``response_headers`` as an instance argument. + The :class:`SimpleHTTPRequestHandler` class can be used in the following manner in order to create a very basic webserver serving files relative to the current directory:: @@ -543,6 +553,14 @@ The following options are accepted: .. versionadded:: 3.14 +.. option:: --cors + + Adds an additional CORS (Cross-Origin Resource sharing) header to each response:: + + Access-Control-Allow-Origin: * + + .. versionadded:: 3.15 + .. _http.server-security: diff --git a/Lib/http/server.py b/Lib/http/server.py index ef10d185932633..152a4275d32ab5 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -117,6 +117,10 @@ class HTTPServer(socketserver.TCPServer): allow_reuse_address = True # Seems to make sense in testing environment allow_reuse_port = True + def __init__(self, *args, response_headers=None, **kwargs): + self.response_headers = response_headers if response_headers is not None else {} + super().__init__(*args, **kwargs) + def server_bind(self): """Override server_bind to store the server name.""" socketserver.TCPServer.server_bind(self) @@ -124,6 +128,11 @@ def server_bind(self): self.server_name = socket.getfqdn(host) self.server_port = port + def finish_request(self, request, client_address): + """Finish one request by instantiating RequestHandlerClass.""" + args = (request, client_address, self) + kwargs = dict(response_headers=self.response_headers) if self.response_headers else dict() + self.RequestHandlerClass(*args, **kwargs) class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer): daemon_threads = True @@ -132,7 +141,7 @@ class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer): class HTTPSServer(HTTPServer): def __init__(self, server_address, RequestHandlerClass, bind_and_activate=True, *, certfile, keyfile=None, - password=None, alpn_protocols=None): + password=None, alpn_protocols=None, response_headers=None): try: import ssl except ImportError: @@ -150,7 +159,8 @@ def __init__(self, server_address, RequestHandlerClass, super().__init__(server_address, RequestHandlerClass, - bind_and_activate) + bind_and_activate, + response_headers=response_headers) def server_activate(self): """Wrap the socket in SSLSocket.""" @@ -692,10 +702,11 @@ class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): '.xz': 'application/x-xz', } - def __init__(self, *args, directory=None, **kwargs): + def __init__(self, *args, directory=None, response_headers=None, **kwargs): if directory is None: directory = os.getcwd() self.directory = os.fspath(directory) + self.response_headers = response_headers or {} super().__init__(*args, **kwargs) def do_GET(self): @@ -736,6 +747,10 @@ def send_head(self): new_url = urllib.parse.urlunsplit(new_parts) self.send_header("Location", new_url) self.send_header("Content-Length", "0") + # User specified response_headers + if self.response_headers is not None: + for header, value in self.response_headers.items(): + self.send_header(header, value) self.end_headers() return None for index in self.index_pages: @@ -795,6 +810,9 @@ def send_head(self): self.send_header("Content-Length", str(fs[6])) self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) + if self.response_headers is not None: + for header, value in self.response_headers.items(): + self.send_header(header, value) self.end_headers() return f except: @@ -970,7 +988,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_password=None): + tls_cert=None, tls_key=None, tls_password=None, response_headers=None): """Test the HTTP request handler class. This runs an HTTP server on port 8000 (or the port argument). @@ -981,9 +999,10 @@ def test(HandlerClass=BaseHTTPRequestHandler, if tls_cert: server = ServerClass(addr, HandlerClass, certfile=tls_cert, - keyfile=tls_key, password=tls_password) + keyfile=tls_key, password=tls_password, + response_headers=response_headers) else: - server = ServerClass(addr, HandlerClass) + server = ServerClass(addr, HandlerClass, response_headers=response_headers) with server as httpd: host, port = httpd.socket.getsockname()[:2] @@ -1024,6 +1043,8 @@ def _main(args=None): parser.add_argument('port', default=8000, type=int, nargs='?', help='bind to this port ' '(default: %(default)s)') + parser.add_argument('--cors', action='store_true', + help='Enable Access-Control-Allow-Origin: * header') args = parser.parse_args(args) if not args.tls_cert and args.tls_key: @@ -1051,8 +1072,11 @@ def server_bind(self): return super().server_bind() def finish_request(self, request, client_address): - self.RequestHandlerClass(request, client_address, self, - directory=args.directory) + handler_args = (request, client_address, self) + handler_kwargs = dict(directory=args.directory) + if self.response_headers: + handler_kwargs['response_headers'] = self.response_headers + self.RequestHandlerClass(*handler_args, **handler_kwargs) class HTTPDualStackServer(DualStackServerMixin, ThreadingHTTPServer): pass @@ -1060,6 +1084,7 @@ class HTTPSDualStackServer(DualStackServerMixin, ThreadingHTTPSServer): pass ServerClass = HTTPSDualStackServer if args.tls_cert else HTTPDualStackServer + response_headers = {'Access-Control-Allow-Origin': '*'} if args.cors else None test( HandlerClass=SimpleHTTPRequestHandler, @@ -1070,6 +1095,7 @@ class HTTPSDualStackServer(DualStackServerMixin, ThreadingHTTPSServer): tls_cert=args.tls_cert, tls_key=args.tls_key, tls_password=tls_key_password, + response_headers=response_headers ) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 2548a7c5f292f0..b1b711b0387db0 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -81,11 +81,12 @@ def test_https_server_raises_runtime_error(self): class TestServerThread(threading.Thread): - def __init__(self, test_object, request_handler, tls=None): + def __init__(self, test_object, request_handler, tls=None, server_kwargs=None): threading.Thread.__init__(self) self.request_handler = request_handler self.test_object = test_object self.tls = tls + self.server_kwargs = server_kwargs or {} def run(self): if self.tls: @@ -95,7 +96,8 @@ def run(self): request_handler=self.request_handler, ) else: - self.server = HTTPServer(('localhost', 0), self.request_handler) + self.server = HTTPServer(('localhost', 0), self.request_handler, + **self.server_kwargs) self.test_object.HOST, self.test_object.PORT = self.server.socket.getsockname() self.test_object.server_started.set() self.test_object = None @@ -113,12 +115,14 @@ class BaseTestCase(unittest.TestCase): # Optional tuple (certfile, keyfile, password) to use for HTTPS servers. tls = None + server_kwargs = 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.tls) + self.thread = TestServerThread(self, self.request_handler, self.tls, + self.server_kwargs) self.thread.start() self.server_started.wait() @@ -824,6 +828,16 @@ def test_path_without_leading_slash(self): self.tempdir_name + "/?hi=1") +class CorsHTTPServerTestCase(SimpleHTTPServerTestCase): + server_kwargs = dict( + response_headers = {'Access-Control-Allow-Origin': '*'} + ) + def test_cors(self): + response = self.request(self.base_url + '/test') + self.check_status_and_reason(response, HTTPStatus.OK) + self.assertEqual(response.getheader('Access-Control-Allow-Origin'), '*') + + class SocketlessRequestHandler(SimpleHTTPRequestHandler): def __init__(self, directory=None): request = mock.Mock() @@ -1306,6 +1320,7 @@ class CommandLineTestCase(unittest.TestCase): 'tls_cert': None, 'tls_key': None, 'tls_password': None, + 'response_headers': None, } def setUp(self): @@ -1371,6 +1386,17 @@ def test_protocol_flag(self, mock_func): mock_func.assert_called_once_with(**call_args) mock_func.reset_mock() + @mock.patch('http.server.test') + def test_cors_flag(self, mock_func): + self.invoke_httpd('--cors') + call_args = self.args | dict( + response_headers={ + 'Access-Control-Allow-Origin': '*' + } + ) + mock_func.assert_called_once_with(**call_args) + mock_func.reset_mock() + @unittest.skipIf(ssl is None, "requires ssl") @mock.patch('http.server.test') def test_tls_cert_and_key_flags(self, mock_func): diff --git a/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst b/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst new file mode 100644 index 00000000000000..d6fa033573e25b --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst @@ -0,0 +1,2 @@ +Add a ``--cors`` cli option to ``python -m http.server``. Contributed by +Anton I. Sipos From 1838da7f9bf7ab91f7699120a9fc0d24c8501edd Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 2 Jun 2025 22:43:23 -0700 Subject: [PATCH 2/3] gh-issue-135056: Fix doc versionchanged and NEWS entries. --- Doc/library/http.server.rst | 6 +++--- .../Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 0f40214b771d2f..ab53f71c030c96 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -374,7 +374,7 @@ instantiation, of which this module provides three different variants: .. versionchanged:: 3.9 The *directory* parameter accepts a :term:`path-like object`. - .. versionchanged:: 3.15 + .. versionchanged:: next The *response_headers* parameter accepts an optional dictionary of additional HTTP headers to add to each response. @@ -444,7 +444,7 @@ instantiation, of which this module provides three different variants: .. versionchanged:: 3.7 Support of the ``'If-Modified-Since'`` header. - .. versionchanged:: 3.15 + .. versionchanged:: next Support ``response_headers`` as an instance argument. The :class:`SimpleHTTPRequestHandler` class can be used in the following @@ -559,7 +559,7 @@ The following options are accepted: Access-Control-Allow-Origin: * - .. versionadded:: 3.15 + .. versionadded:: next .. _http.server-security: diff --git a/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst b/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst index d6fa033573e25b..929a4d08d19834 100644 --- a/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst +++ b/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst @@ -1,2 +1,2 @@ -Add a ``--cors`` cli option to ``python -m http.server``. Contributed by -Anton I. Sipos +Add a ``--cors`` cli option to :program:`python -m http.server`. Contributed by +Anton I. Sipos. From a3256fd21ba1d1c420e77a0007bea186a2f1f6a2 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 2 Jun 2025 23:09:49 -0700 Subject: [PATCH 3/3] gh-13056: Allow unspecified response_headers in HTTPServer. This fixes the breakage to HttpServer as used by wsgiref. --- 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 152a4275d32ab5..1dfe735e688276 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -118,7 +118,7 @@ class HTTPServer(socketserver.TCPServer): allow_reuse_port = True def __init__(self, *args, response_headers=None, **kwargs): - self.response_headers = response_headers if response_headers is not None else {} + self.response_headers = response_headers super().__init__(*args, **kwargs) def server_bind(self): @@ -131,7 +131,10 @@ def server_bind(self): def finish_request(self, request, client_address): """Finish one request by instantiating RequestHandlerClass.""" args = (request, client_address, self) - kwargs = dict(response_headers=self.response_headers) if self.response_headers else dict() + kwargs = {} + response_headers = getattr(self, 'response_headers', None) + if response_headers: + kwargs['response_headers'] = self.response_headers self.RequestHandlerClass(*args, **kwargs) class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer):