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 <10796600+picnixz@users.noreply.github.com>
Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com>
This commit is contained in:
Anton Ian Sipos 2026-05-03 04:01:37 -07:00 committed by GitHub
parent 726a17e265
commit 836fbdaaf3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 193 additions and 17 deletions

View file

@ -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:

View file

@ -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
-------

View file

@ -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

View file

@ -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)

View file

@ -0,0 +1,2 @@
Add a ``-H`` or ``--header`` CLI option to :program:`python -m http.server`. Contributed by
Anton I. Sipos.