Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 35.2k
gh-135056: Add a --header CLI argument to http.server#135057
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
0d02fbe1838da7a3256fd77b5fff5f89c97a3243feb1026d26f88c137a793f29450b865a30d91d317cc25f1fb94c376a7189a89f09653710f3ae90444efbedd47c5a78d1286adb9de68e149708c16f4c9777b5b6eac5c6ac9c8083c2d6bb33377cf706a9977fae21f9be7851553965ffc280ed864122dff0d1bace99780e2e829bb8baa8757856d27303ab5bed0b0b379c577b526e49946c1c91c1fee3b3a4fed69f0ed011036a9149ffc92511d90244205e0280cea3d1889779f7ac479851622c1ab0ce69e59e9c40f8b1597b973347b968a249eeaFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
picnixz marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| .. _http.server-security: | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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): | ||
picnixz marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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, | ||
picnixz marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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') | ||
hugovk marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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) | ||
hugovk marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| Add a ``-H`` or ``--header`` CLI option to :program:`python -m http.server`. Contributed by | ||
| Anton I. Sipos. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.