diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index cb8b5f0df88d6c..f1524b5c9a8687 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -366,7 +366,8 @@ 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, extra_response_headers=None) This class serves files from the directory *directory* and below, or the current directory if *directory* is not provided, directly @@ -378,6 +379,9 @@ instantiation, of which this module provides three different variants: .. versionchanged:: 3.9 The *directory* parameter accepts a :term:`path-like object`. + .. versionchanged:: next + Added *extra_response_headers* parameter. + 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. @@ -400,6 +404,15 @@ instantiation, of which this module provides three different variants: This dictionary is no longer filled with the default system mappings, but only contains overrides. + .. attribute:: extra_response_headers + + A sequence of ``(name, value)`` pairs containing user-defined extra HTTP + response headers to add to each successful HTTP status 200 response. These + headers are not included in other status code responses. + + Headers that the server sends automatically (for instance Content-Type) + will not be overwritten by extra_response_headers. + The :class:`SimpleHTTPRequestHandler` class defines the following methods: .. method:: do_HEAD() @@ -432,6 +445,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 instance attribute ``extra_response_headers`` is a sequence of + ``(name, value)`` pairs containing user-defined extra response headers. + Then follows a blank line signifying the end of the headers, and then the contents of the file are output. @@ -547,6 +563,15 @@ The following options are accepted: .. versionadded:: 3.14 +.. option:: -H, --header
+ + Specify an additional extra HTTP Response Header to send on successful HTTP + 200 responses. Can be used multiple times to send additional custom response + headers. Headers that are sent automatically by the server (for instance + Content-Type) will not be overwritten by the server. + + .. versionadded:: next + .. _http.server-security: diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index c754b634ecccfa..0a77beded5a4fb 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -832,6 +832,15 @@ http.server `. (Contributed by Hugo van Kemenade in :gh:`146292`.) +* Add a new ``extra_response_headers`` keyword argument to + :class:`~http.server.SimpleHTTPRequestHandler` to support custom headers in + HTTP responses. + (Contributed by Anton I. Sipos in :gh:`135057`.) + +* Add a ``-H/--header`` option to the :program:`python -m http.server` + command-line interface to support custom headers in HTTP responses. + (Contributed by Anton I. Sipos in :gh:`135057`.) + inspect ------- diff --git a/Lib/http/server.py b/Lib/http/server.py index 568d3bb38deb6c..a95c0cb8523d6e 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -466,6 +466,7 @@ def handle_one_request(self): def handle(self): """Handle multiple requests if necessary.""" self.close_connection = True + self.default_response_headers = [] self.handle_one_request() while not self.close_connection: @@ -551,13 +552,15 @@ def send_response_only(self, code, message=None): (self.protocol_version, code, message)).encode( 'latin-1', 'strict')) - def send_header(self, keyword, value): + def send_header(self, keyword, value, is_extra=False): """Send a MIME header to the 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')) + if not is_extra and hasattr(self, 'default_response_headers'): + self.default_response_headers.append((keyword, value)) if keyword.lower() == 'connection': if value.lower() == 'close': @@ -735,10 +738,11 @@ class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): '.xz': 'application/x-xz', } - def __init__(self, *args, directory=None, **kwargs): + def __init__(self, *args, directory=None, extra_response_headers=None, **kwargs): if directory is None: directory = os.getcwd() self.directory = os.fspath(directory) + self.extra_response_headers = extra_response_headers super().__init__(*args, **kwargs) def do_GET(self): @@ -756,6 +760,14 @@ def do_HEAD(self): if f: f.close() + def _send_extra_response_headers(self): + """Send the headers stored in self.extra_response_headers""" + if self.extra_response_headers is not None: + for header, value in self.extra_response_headers: + # Don't send the header if it's already sent as part of the default response headers + if header.lower() not in (h.lower() for h, _ in self.default_response_headers): + self.send_header(header, value, is_extra=True) + def send_head(self): """Common code for GET and HEAD commands. @@ -838,6 +850,7 @@ def send_head(self): self.send_header("Content-Length", str(fs[6])) self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) + self._send_extra_response_headers() self.end_headers() return f except: @@ -902,6 +915,7 @@ def list_directory(self, path): self.send_response(HTTPStatus.OK) self.send_header("Content-type", "text/html; charset=%s" % enc) self.send_header("Content-Length", str(len(encoded))) + self._send_extra_response_headers() self.end_headers() return f @@ -1010,6 +1024,20 @@ def _get_best_family(*address): return family, sockaddr +def _make_server(HandlerClass=BaseHTTPRequestHandler, + ServerClass=ThreadingHTTPServer, + protocol="HTTP/1.0", port=8000, bind=None, + tls_cert=None, tls_key=None, tls_password=None): + ServerClass.address_family, addr = _get_best_family(bind, port) + HandlerClass.protocol_version = protocol + + if tls_cert: + return ServerClass(addr, HandlerClass, certfile=tls_cert, + keyfile=tls_key, password=tls_password) + else: + return ServerClass(addr, HandlerClass) + + def test(HandlerClass=BaseHTTPRequestHandler, ServerClass=ThreadingHTTPServer, protocol="HTTP/1.0", port=8000, bind=None, @@ -1017,18 +1045,12 @@ def test(HandlerClass=BaseHTTPRequestHandler, """Test the HTTP request handler class. This runs an HTTP server on port 8000 (or the port argument). - """ - ServerClass.address_family, addr = _get_best_family(bind, port) - HandlerClass.protocol_version = protocol - - if tls_cert: - server = ServerClass(addr, HandlerClass, certfile=tls_cert, - keyfile=tls_key, password=tls_password) - else: - server = ServerClass(addr, HandlerClass) - - with server as httpd: + with _make_server( + HandlerClass=HandlerClass, ServerClass=ServerClass, + protocol=protocol, port=port, bind=bind, + tls_cert=tls_cert, tls_key=tls_key, tls_password=tls_password + ) as httpd: host, port = httpd.socket.getsockname()[:2] url_host = f'[{host}]' if ':' in host else host protocol = 'HTTPS' if tls_cert else 'HTTP' @@ -1069,6 +1091,10 @@ def _main(args=None): parser.add_argument('port', default=8000, type=int, nargs='?', help='bind to this port ' '(default: %(default)s)') + parser.add_argument('-H', '--header', nargs=2, action='append', + metavar=('HEADER', 'VALUE'), + help='Add a custom response header ' + '(can be specified multiple times)') args = parser.parse_args(args) if not args.tls_cert and args.tls_key: @@ -1097,7 +1123,8 @@ def server_bind(self): def finish_request(self, request, client_address): self.RequestHandlerClass(request, client_address, self, - directory=args.directory) + directory=args.directory, + extra_response_headers=args.header) class HTTPDualStackServer(DualStackServerMixin, ThreadingHTTPServer): pass diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index d78b94e3a373d4..d666157c2d1d53 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -540,8 +540,16 @@ def test_err(self): self.assertIn(f"{t.status_client_error}404", lines[1]) +class CustomHeaderSimpleHTTPRequestHandler(SimpleHTTPRequestHandler): + extra_response_headers = None + + def __init__(self, *args, **kwargs): + kwargs.setdefault('extra_response_headers', self.extra_response_headers) + super().__init__(*args, **kwargs) + + class SimpleHTTPServerTestCase(BaseTestCase): - class request_handler(NoLogRequestHandler, SimpleHTTPRequestHandler): + class request_handler(NoLogRequestHandler, CustomHeaderSimpleHTTPRequestHandler): pass def setUp(self): @@ -898,6 +906,55 @@ def test_path_without_leading_slash(self): self.assertEqual(response.getheader("Location"), self.tempdir_name + "/?hi=1") + def test_extra_response_headers_list_dir(self): + with mock.patch.object(self.request_handler, 'extra_response_headers', [ + ('X-Test1', 'test1'), + ('X-Test2', 'test2'), + ]): + response = self.request(self.base_url + '/') + self.assertEqual(response.status, 200) + self.assertEqual(response.getheader("X-Test1"), 'test1') + self.assertEqual(response.getheader("X-Test2"), 'test2') + + def test_extra_response_headers_get_file(self): + with mock.patch.object(self.request_handler, 'extra_response_headers', [ + ('Set-Cookie', 'test1=value1'), + ('Set-Cookie', 'test2=value2'), + ('X-Test1', 'value3'), + ]): + data = b"Dummy index file\r\n" + with open(os.path.join(self.tempdir_name, 'index.html'), 'wb') as f: + f.write(data) + response = self.request(self.base_url + '/') + self.assertEqual(response.status, 200) + self.assertEqual(response.getheader("Set-Cookie"), + 'test1=value1, test2=value2') + self.assertEqual(response.getheader("X-Test1"), 'value3') + + def test_extra_response_headers_missing_on_404(self): + with mock.patch.object(self.request_handler, 'extra_response_headers', [ + ('X-Test1', 'value'), + ]): + response = self.request(self.base_url + '/missing.html') + self.assertEqual(response.status, 404) + self.assertEqual(response.getheader("X-Test1"), None) + + def test_extra_response_headers_dont_overwrite_default_headers(self): + with mock.patch.object(self.request_handler, 'extra_response_headers', [ + ('Content-Type', 'test/not_allowed'), + ('Server', 'not_allowed'), + ('Set-Cookie', 'test=allowed'), + ]): + # The Content-Type header should not be overwritten by the extra_response_headers + # But cookies in the extra_allowed_duplicate_headers are allowed, + # including Set-Cookie + response = self.request(self.base_url + '/') + self.assertEqual(response.status, 200) + self.assertNotEqual(response.getheader("Content-Type"), 'test/not_allowed') + self.assertNotEqual(response.getheader("Server"), 'not_allowed') + self.assertEqual(response.getheader("Set-Cookie"), 'test=allowed') + + class SocketlessRequestHandler(SimpleHTTPRequestHandler): def __init__(self, directory=None): @@ -1447,6 +1504,21 @@ 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_header_flag(self, mock_func): + call_args = self.args + self.invoke_httpd('--header', 'h1', 'v1', '-H', 'h2', 'v2') + mock_func.assert_called_once_with(**call_args) + mock_func.reset_mock() + + def test_extra_header_flag_too_few_args(self): + with self.assertRaises(SystemExit): + self.invoke_httpd('--header', 'h1') + + def test_extra_header_flag_too_many_args(self): + with self.assertRaises(SystemExit): + self.invoke_httpd('--header', 'h1', 'v1', 'h2') + @unittest.skipIf(ssl is None, "requires ssl") @mock.patch('http.server.test') def test_tls_cert_and_key_flags(self, mock_func): @@ -1530,6 +1602,36 @@ def test_unknown_flag(self, _): self.assertEqual(stdout.getvalue(), '') self.assertIn('error', stderr.getvalue()) + @mock.patch('http.server._make_server', wraps=server._make_server) + @mock.patch.object(HTTPServer, 'serve_forever') + def test_extra_response_headers_arg(self, _, mock_make_server): + server._main( + ['-H', 'Set-Cookie', 'k=v', '-H', 'Set-Cookie', 'k2=v2:v3 v4', '8080'] + ) + # Get an instance of the server / RequestHandler by using + # the spied call args, then calling _make_server with them. + args, kwargs = mock_make_server.call_args + httpd = server._make_server(*args, **kwargs) + self.addCleanup(httpd.server_close) + + # Ensure the RequestHandler class is passed the correct response + # headers + request_handler_class = httpd.RequestHandlerClass + with mock.patch.object( + request_handler_class, '__init__' + ) as mock_handler_init: + mock_handler_init.return_value = None + # finish_request instantiates a request handler class, + # ensure extra_response_headers are passed to it + httpd.finish_request(mock.Mock(), '127.0.0.1') + mock_handler_init.assert_called_once_with( + mock.ANY, mock.ANY, mock.ANY, + directory=mock.ANY, + extra_response_headers=[ + ['Set-Cookie', 'k=v'], ['Set-Cookie', 'k2=v2:v3 v4'] + ] + ) + class CommandLineRunTimeTestCase(unittest.TestCase): served_data = os.urandom(32) 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..754df083ab1063 --- /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 ``-H`` or ``--header`` CLI option to :program:`python -m http.server`. Contributed by +Anton I. Sipos.