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>
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
.. 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.
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()
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.
.. 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:
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
-------
(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':
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:
'.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):
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.
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:
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
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,
"""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'
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:
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
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):
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):
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):
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)
--- /dev/null
+Add a ``-H`` or ``--header`` CLI option to :program:`python -m http.server`. Contributed by
+Anton I. Sipos.