https://github.com/python/cpython/commit/836fbdaaf32c355c7e8fb0af69f78fbbb28af8b1
commit: 836fbdaaf32c355c7e8fb0af69f78fbbb28af8b1
branch: main
author: Anton Ian Sipos <[email protected]>
committer: picnixz <[email protected]>
date: 2026-05-03T13:01:37+02:00
summary:

gh-135056: Add a `--header` CLI option to `http.server` (#135057)

Support custom headers in `python -m http.server` and 
`http.server.SimpleHTTPRequestHandler`.

Co-authored-by: Bénédikt Tran <[email protected]>
Co-authored-by: Hugo van Kemenade <[email protected]>

files:
A Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst
M Doc/library/http.server.rst
M Doc/whatsnew/3.15.rst
M Lib/http/server.py
M Lib/test/test_httpservers.py

diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst
index 5f325df55705bd..772f2633b29443 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.
@@ -408,6 +412,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 such as ``Content-Type``
+      will not be overwritten by :attr:`!extra_response_headers`.
+
    The :class:`SimpleHTTPRequestHandler` class defines the following methods:
 
    .. method:: do_HEAD()
@@ -440,6 +453,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 :attr:`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.
 
@@ -581,6 +597,15 @@ The following options are accepted:
 
    .. versionadded:: 3.14
 
+.. option:: -H, --header <header> <value>
+
+   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 bf7aecc4c35bd1..61a440d2ad6f8d 100644
--- a/Doc/whatsnew/3.15.rst
+++ b/Doc/whatsnew/3.15.rst
@@ -974,6 +974,15 @@ http.server
   for files with unknown extensions.
   (Contributed by John Comeau and Hugo van Kemenade in :gh:`113471`.)
 
+* 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 27ab37303a085c..16ea7f3f93693f 100644
--- a/Lib/http/server.py
+++ b/Lib/http/server.py
@@ -551,13 +551,17 @@ 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 hasattr(self, '_default_response_headers'):
+                self._default_response_headers = []
+            if not _is_extra:
+                self._default_response_headers.append((keyword, value))
 
         if keyword.lower() == 'connection':
             if value.lower() == 'close':
@@ -575,6 +579,8 @@ def flush_headers(self):
         if hasattr(self, '_headers_buffer'):
             self.wfile.write(b"".join(self._headers_buffer))
             self._headers_buffer = []
+        if hasattr(self, '_default_response_headers'):
+            self._default_response_headers = []
 
     def _colorize_request(self, code, size, t):
         try:
@@ -736,10 +742,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):
@@ -757,6 +764,16 @@ 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:
+            default_headers = {h.lower() for h, _ in 
self._default_response_headers}
+            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 default_headers:
+                    self.send_header(header, value, _is_extra=True)
+
     def send_head(self):
         """Common code for GET and HEAD commands.
 
@@ -839,6 +856,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:
@@ -903,6 +921,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
 
@@ -1011,6 +1030,22 @@ 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,
+                 
default_content_type=SimpleHTTPRequestHandler.default_content_type):
+    ServerClass.address_family, addr = _get_best_family(bind, port)
+    HandlerClass.protocol_version = protocol
+    HandlerClass.default_content_type = default_content_type
+
+    if tls_cert:
+        return ServerClass(addr, HandlerClass, certfile=tls_cert,
+                           keyfile=tls_key, password=tls_password)
+    else:
+        return ServerClass(addr, HandlerClass)
+
+
 def test(HandlerClass=SimpleHTTPRequestHandler,
          ServerClass=ThreadingHTTPServer,
          protocol="HTTP/1.0", port=8000, bind=None,
@@ -1019,19 +1054,13 @@ def test(HandlerClass=SimpleHTTPRequestHandler,
     """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
-    HandlerClass.default_content_type = content_type
-
-    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,
+        default_content_type=content_type,
+    ) as httpd:
         host, port = httpd.socket.getsockname()[:2]
         url_host = f'[{host}]' if ':' in host else host
         protocol = 'HTTPS' if tls_cert else 'HTTP'
@@ -1076,6 +1105,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:
@@ -1104,7 +1137,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 1f7a5a42fdaeb7..44fe6f771de2cb 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,65 @@ 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')
+
+    def test_multiple_requests_dont_duplicate_extra_response_headers(self):
+        with mock.patch.object(self.request_handler, 'extra_response_headers', 
[
+            ('x-test', 'test-value'),
+        ]):
+            response = self.request(self.base_url + '/')
+            self.assertEqual(response.status, 200)
+            self.assertEqual(response.getheader("x-test"), 'test-value')
+            response = self.request(self.base_url + '/')
+            self.assertEqual(response.status, 200)
+            self.assertEqual(response.getheader("x-test"), 'test-value')
+
 
 class SocketlessRequestHandler(SimpleHTTPRequestHandler):
     def __init__(self, directory=None):
@@ -1458,6 +1525,21 @@ def test_content_type_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):
@@ -1541,6 +1623,30 @@ def test_unknown_flag(self, _):
         self.assertEqual(stdout.getvalue(), '')
         self.assertIn('error', stderr.getvalue())
 
+    @mock.patch('http.server.test')
+    def test_extra_response_headers_arg(self, mock_test):
+        # Call the main function with extra response headers cli args
+        server._main(
+            ['-H', 'Set-Cookie', 'k=v', '-H', 'Set-Cookie', 'k2=v2:v3 v4', 
'8080']
+        )
+        # Get the ServerClass (DualStackServerMixin subclass) that _main()
+        # passed to test(), and verify its finish_request passes
+        # extra_response_headers to the handler.
+        _, kwargs = mock_test.call_args
+        server_class = kwargs['ServerClass']
+
+        mock_handler_class = mock.MagicMock()
+        mock_server = mock.Mock()
+        mock_server.RequestHandlerClass = mock_handler_class
+        server_class.finish_request(mock_server, mock.Mock(), '127.0.0.1')
+        mock_handler_class.assert_called_once_with(
+            mock.ANY, mock.ANY, mock_server,
+            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.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to