Skip to content

Commit 836fbda

Browse files
aisipospicnixzhugovk
authored
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>
1 parent 726a17e commit 836fbda

5 files changed

Lines changed: 193 additions & 17 deletions

File tree

Doc/library/http.server.rst

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,8 @@ instantiation, of which this module provides three different variants:
366366
delays, it now always returns the IP address.
367367

368368

369-
.. class:: SimpleHTTPRequestHandler(request, client_address, server, directory=None)
369+
.. class:: SimpleHTTPRequestHandler(request, client_address, server, \
370+
*, directory=None, extra_response_headers=None)
370371
371372
This class serves files from the directory *directory* and below,
372373
or the current directory if *directory* is not provided, directly
@@ -378,6 +379,9 @@ instantiation, of which this module provides three different variants:
378379
.. versionchanged:: 3.9
379380
The *directory* parameter accepts a :term:`path-like object`.
380381

382+
.. versionchanged:: next
383+
Added *extra_response_headers* parameter.
384+
381385
A lot of the work, such as parsing the request, is done by the base class
382386
:class:`BaseHTTPRequestHandler`. This class implements the :func:`do_GET`
383387
and :func:`do_HEAD` functions.
@@ -408,6 +412,15 @@ instantiation, of which this module provides three different variants:
408412
This dictionary is no longer filled with the default system mappings,
409413
but only contains overrides.
410414

415+
.. attribute:: extra_response_headers
416+
417+
A sequence of ``(name, value)`` pairs containing user-defined extra HTTP
418+
response headers to add to each successful HTTP status 200 response. These
419+
headers are not included in other status code responses.
420+
421+
Headers that the server sends automatically such as ``Content-Type``
422+
will not be overwritten by :attr:`!extra_response_headers`.
423+
411424
The :class:`SimpleHTTPRequestHandler` class defines the following methods:
412425

413426
.. method:: do_HEAD()
@@ -440,6 +453,9 @@ instantiation, of which this module provides three different variants:
440453
followed by a ``'Content-Length:'`` header with the file's size and a
441454
``'Last-Modified:'`` header with the file's modification time.
442455

456+
The instance attribute :attr:`extra_response_headers` is a sequence of
457+
``(name, value)`` pairs containing user-defined extra response headers.
458+
443459
Then follows a blank line signifying the end of the headers, and then the
444460
contents of the file are output.
445461

@@ -581,6 +597,15 @@ The following options are accepted:
581597

582598
.. versionadded:: 3.14
583599

600+
.. option:: -H, --header <header> <value>
601+
602+
Specify an additional extra HTTP Response Header to send on successful HTTP
603+
200 responses. Can be used multiple times to send additional custom response
604+
headers. Headers that are sent automatically by the server (for instance
605+
Content-Type) will not be overwritten by the server.
606+
607+
.. versionadded:: next
608+
584609

585610
.. _http.server-security:
586611

Doc/whatsnew/3.15.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,15 @@ http.server
974974
for files with unknown extensions.
975975
(Contributed by John Comeau and Hugo van Kemenade in :gh:`113471`.)
976976

977+
* Add a new ``extra_response_headers`` keyword argument to
978+
:class:`~http.server.SimpleHTTPRequestHandler` to support custom headers in
979+
HTTP responses.
980+
(Contributed by Anton I. Sipos in :gh:`135057`.)
981+
982+
* Add a ``-H/--header`` option to the :program:`python -m http.server`
983+
command-line interface to support custom headers in HTTP responses.
984+
(Contributed by Anton I. Sipos in :gh:`135057`.)
985+
977986

978987
inspect
979988
-------

Lib/http/server.py

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -551,13 +551,17 @@ def send_response_only(self, code, message=None):
551551
(self.protocol_version, code, message)).encode(
552552
'latin-1', 'strict'))
553553

554-
def send_header(self, keyword, value):
554+
def send_header(self, keyword, value, *, _is_extra=False):
555555
"""Send a MIME header to the headers buffer."""
556556
if self.request_version != 'HTTP/0.9':
557557
if not hasattr(self, '_headers_buffer'):
558558
self._headers_buffer = []
559559
self._headers_buffer.append(
560560
("%s: %s\r\n" % (keyword, value)).encode('latin-1', 'strict'))
561+
if not hasattr(self, '_default_response_headers'):
562+
self._default_response_headers = []
563+
if not _is_extra:
564+
self._default_response_headers.append((keyword, value))
561565

562566
if keyword.lower() == 'connection':
563567
if value.lower() == 'close':
@@ -575,6 +579,8 @@ def flush_headers(self):
575579
if hasattr(self, '_headers_buffer'):
576580
self.wfile.write(b"".join(self._headers_buffer))
577581
self._headers_buffer = []
582+
if hasattr(self, '_default_response_headers'):
583+
self._default_response_headers = []
578584

579585
def _colorize_request(self, code, size, t):
580586
try:
@@ -736,10 +742,11 @@ class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):
736742
'.xz': 'application/x-xz',
737743
}
738744

739-
def __init__(self, *args, directory=None, **kwargs):
745+
def __init__(self, *args, directory=None, extra_response_headers=None, **kwargs):
740746
if directory is None:
741747
directory = os.getcwd()
742748
self.directory = os.fspath(directory)
749+
self.extra_response_headers = extra_response_headers
743750
super().__init__(*args, **kwargs)
744751

745752
def do_GET(self):
@@ -757,6 +764,16 @@ def do_HEAD(self):
757764
if f:
758765
f.close()
759766

767+
def _send_extra_response_headers(self):
768+
"""Send the headers stored in self.extra_response_headers."""
769+
if self.extra_response_headers is not None:
770+
default_headers = {h.lower() for h, _ in self._default_response_headers}
771+
for header, value in self.extra_response_headers:
772+
# Don't send the header if it's already sent
773+
# as part of the default response headers
774+
if header.lower() not in default_headers:
775+
self.send_header(header, value, _is_extra=True)
776+
760777
def send_head(self):
761778
"""Common code for GET and HEAD commands.
762779
@@ -839,6 +856,7 @@ def send_head(self):
839856
self.send_header("Content-Length", str(fs[6]))
840857
self.send_header("Last-Modified",
841858
self.date_time_string(fs.st_mtime))
859+
self._send_extra_response_headers()
842860
self.end_headers()
843861
return f
844862
except:
@@ -903,6 +921,7 @@ def list_directory(self, path):
903921
self.send_response(HTTPStatus.OK)
904922
self.send_header("Content-type", "text/html; charset=%s" % enc)
905923
self.send_header("Content-Length", str(len(encoded)))
924+
self._send_extra_response_headers()
906925
self.end_headers()
907926
return f
908927

@@ -1011,6 +1030,22 @@ def _get_best_family(*address):
10111030
return family, sockaddr
10121031

10131032

1033+
def _make_server(HandlerClass=BaseHTTPRequestHandler,
1034+
ServerClass=ThreadingHTTPServer,
1035+
protocol="HTTP/1.0", port=8000, bind=None,
1036+
tls_cert=None, tls_key=None, tls_password=None,
1037+
default_content_type=SimpleHTTPRequestHandler.default_content_type):
1038+
ServerClass.address_family, addr = _get_best_family(bind, port)
1039+
HandlerClass.protocol_version = protocol
1040+
HandlerClass.default_content_type = default_content_type
1041+
1042+
if tls_cert:
1043+
return ServerClass(addr, HandlerClass, certfile=tls_cert,
1044+
keyfile=tls_key, password=tls_password)
1045+
else:
1046+
return ServerClass(addr, HandlerClass)
1047+
1048+
10141049
def test(HandlerClass=SimpleHTTPRequestHandler,
10151050
ServerClass=ThreadingHTTPServer,
10161051
protocol="HTTP/1.0", port=8000, bind=None,
@@ -1019,19 +1054,13 @@ def test(HandlerClass=SimpleHTTPRequestHandler,
10191054
"""Test the HTTP request handler class.
10201055
10211056
This runs an HTTP server on port 8000 (or the port argument).
1022-
10231057
"""
1024-
ServerClass.address_family, addr = _get_best_family(bind, port)
1025-
HandlerClass.protocol_version = protocol
1026-
HandlerClass.default_content_type = content_type
1027-
1028-
if tls_cert:
1029-
server = ServerClass(addr, HandlerClass, certfile=tls_cert,
1030-
keyfile=tls_key, password=tls_password)
1031-
else:
1032-
server = ServerClass(addr, HandlerClass)
1033-
1034-
with server as httpd:
1058+
with _make_server(
1059+
HandlerClass=HandlerClass, ServerClass=ServerClass,
1060+
protocol=protocol, port=port, bind=bind,
1061+
tls_cert=tls_cert, tls_key=tls_key, tls_password=tls_password,
1062+
default_content_type=content_type,
1063+
) as httpd:
10351064
host, port = httpd.socket.getsockname()[:2]
10361065
url_host = f'[{host}]' if ':' in host else host
10371066
protocol = 'HTTPS' if tls_cert else 'HTTP'
@@ -1076,6 +1105,10 @@ def _main(args=None):
10761105
parser.add_argument('port', default=8000, type=int, nargs='?',
10771106
help='bind to this port '
10781107
'(default: %(default)s)')
1108+
parser.add_argument('-H', '--header', nargs=2, action='append',
1109+
metavar=('HEADER', 'VALUE'),
1110+
help='Add a custom response header '
1111+
'(can be specified multiple times)')
10791112
args = parser.parse_args(args)
10801113

10811114
if not args.tls_cert and args.tls_key:
@@ -1104,7 +1137,8 @@ def server_bind(self):
11041137

11051138
def finish_request(self, request, client_address):
11061139
self.RequestHandlerClass(request, client_address, self,
1107-
directory=args.directory)
1140+
directory=args.directory,
1141+
extra_response_headers=args.header)
11081142

11091143
class HTTPDualStackServer(DualStackServerMixin, ThreadingHTTPServer):
11101144
pass

Lib/test/test_httpservers.py

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,8 +540,16 @@ def test_err(self):
540540
self.assertIn(f"{t.status_client_error}404", lines[1])
541541

542542

543+
class CustomHeaderSimpleHTTPRequestHandler(SimpleHTTPRequestHandler):
544+
extra_response_headers = None
545+
546+
def __init__(self, *args, **kwargs):
547+
kwargs.setdefault('extra_response_headers', self.extra_response_headers)
548+
super().__init__(*args, **kwargs)
549+
550+
543551
class SimpleHTTPServerTestCase(BaseTestCase):
544-
class request_handler(NoLogRequestHandler, SimpleHTTPRequestHandler):
552+
class request_handler(NoLogRequestHandler, CustomHeaderSimpleHTTPRequestHandler):
545553
pass
546554

547555
def setUp(self):
@@ -898,6 +906,65 @@ def test_path_without_leading_slash(self):
898906
self.assertEqual(response.getheader("Location"),
899907
self.tempdir_name + "/?hi=1")
900908

909+
def test_extra_response_headers_list_dir(self):
910+
with mock.patch.object(self.request_handler, 'extra_response_headers', [
911+
('X-Test1', 'test1'),
912+
('X-Test2', 'test2'),
913+
]):
914+
response = self.request(self.base_url + '/')
915+
self.assertEqual(response.status, 200)
916+
self.assertEqual(response.getheader("X-Test1"), 'test1')
917+
self.assertEqual(response.getheader("X-Test2"), 'test2')
918+
919+
def test_extra_response_headers_get_file(self):
920+
with mock.patch.object(self.request_handler, 'extra_response_headers', [
921+
('Set-Cookie', 'test1=value1'),
922+
('Set-Cookie', 'test2=value2'),
923+
('X-Test1', 'value3'),
924+
]):
925+
data = b"Dummy index file\r\n"
926+
with open(os.path.join(self.tempdir_name, 'index.html'), 'wb') as f:
927+
f.write(data)
928+
response = self.request(self.base_url + '/')
929+
self.assertEqual(response.status, 200)
930+
self.assertEqual(response.getheader("Set-Cookie"),
931+
'test1=value1, test2=value2')
932+
self.assertEqual(response.getheader("X-Test1"), 'value3')
933+
934+
def test_extra_response_headers_missing_on_404(self):
935+
with mock.patch.object(self.request_handler, 'extra_response_headers', [
936+
('X-Test1', 'value'),
937+
]):
938+
response = self.request(self.base_url + '/missing.html')
939+
self.assertEqual(response.status, 404)
940+
self.assertEqual(response.getheader("X-Test1"), None)
941+
942+
def test_extra_response_headers_dont_overwrite_default_headers(self):
943+
with mock.patch.object(self.request_handler, 'extra_response_headers', [
944+
('Content-Type', 'test/not_allowed'),
945+
('Server', 'not_allowed'),
946+
('Set-Cookie', 'test=allowed'),
947+
]):
948+
# The Content-Type header should not be overwritten by the extra_response_headers
949+
# But cookies in the extra_allowed_duplicate_headers are allowed,
950+
# including Set-Cookie
951+
response = self.request(self.base_url + '/')
952+
self.assertEqual(response.status, 200)
953+
self.assertNotEqual(response.getheader("Content-Type"), 'test/not_allowed')
954+
self.assertNotEqual(response.getheader("Server"), 'not_allowed')
955+
self.assertEqual(response.getheader("Set-Cookie"), 'test=allowed')
956+
957+
def test_multiple_requests_dont_duplicate_extra_response_headers(self):
958+
with mock.patch.object(self.request_handler, 'extra_response_headers', [
959+
('x-test', 'test-value'),
960+
]):
961+
response = self.request(self.base_url + '/')
962+
self.assertEqual(response.status, 200)
963+
self.assertEqual(response.getheader("x-test"), 'test-value')
964+
response = self.request(self.base_url + '/')
965+
self.assertEqual(response.status, 200)
966+
self.assertEqual(response.getheader("x-test"), 'test-value')
967+
901968

902969
class SocketlessRequestHandler(SimpleHTTPRequestHandler):
903970
def __init__(self, directory=None):
@@ -1458,6 +1525,21 @@ def test_content_type_flag(self, mock_func):
14581525
mock_func.assert_called_once_with(**call_args)
14591526
mock_func.reset_mock()
14601527

1528+
@mock.patch('http.server.test')
1529+
def test_header_flag(self, mock_func):
1530+
call_args = self.args
1531+
self.invoke_httpd('--header', 'h1', 'v1', '-H', 'h2', 'v2')
1532+
mock_func.assert_called_once_with(**call_args)
1533+
mock_func.reset_mock()
1534+
1535+
def test_extra_header_flag_too_few_args(self):
1536+
with self.assertRaises(SystemExit):
1537+
self.invoke_httpd('--header', 'h1')
1538+
1539+
def test_extra_header_flag_too_many_args(self):
1540+
with self.assertRaises(SystemExit):
1541+
self.invoke_httpd('--header', 'h1', 'v1', 'h2')
1542+
14611543
@unittest.skipIf(ssl is None, "requires ssl")
14621544
@mock.patch('http.server.test')
14631545
def test_tls_cert_and_key_flags(self, mock_func):
@@ -1541,6 +1623,30 @@ def test_unknown_flag(self, _):
15411623
self.assertEqual(stdout.getvalue(), '')
15421624
self.assertIn('error', stderr.getvalue())
15431625

1626+
@mock.patch('http.server.test')
1627+
def test_extra_response_headers_arg(self, mock_test):
1628+
# Call the main function with extra response headers cli args
1629+
server._main(
1630+
['-H', 'Set-Cookie', 'k=v', '-H', 'Set-Cookie', 'k2=v2:v3 v4', '8080']
1631+
)
1632+
# Get the ServerClass (DualStackServerMixin subclass) that _main()
1633+
# passed to test(), and verify its finish_request passes
1634+
# extra_response_headers to the handler.
1635+
_, kwargs = mock_test.call_args
1636+
server_class = kwargs['ServerClass']
1637+
1638+
mock_handler_class = mock.MagicMock()
1639+
mock_server = mock.Mock()
1640+
mock_server.RequestHandlerClass = mock_handler_class
1641+
server_class.finish_request(mock_server, mock.Mock(), '127.0.0.1')
1642+
mock_handler_class.assert_called_once_with(
1643+
mock.ANY, mock.ANY, mock_server,
1644+
directory=mock.ANY,
1645+
extra_response_headers=[
1646+
['Set-Cookie', 'k=v'], ['Set-Cookie', 'k2=v2:v3 v4']
1647+
]
1648+
)
1649+
15441650

15451651
class CommandLineRunTimeTestCase(unittest.TestCase):
15461652
served_data = os.urandom(32)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add a ``-H`` or ``--header`` CLI option to :program:`python -m http.server`. Contributed by
2+
Anton I. Sipos.

0 commit comments

Comments
 (0)