mirror of
https://github.com/python/cpython.git
synced 2026-05-04 09:31:02 +00:00
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:
parent
726a17e265
commit
836fbdaaf3
5 changed files with 193 additions and 17 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
Add a ``-H`` or ``--header`` CLI option to :program:`python -m http.server`. Contributed by
|
||||
Anton I. Sipos.
|
||||
Loading…
Add table
Add a link
Reference in a new issue