From 5b24f9a6f85bd63ac73cef205f5c01cf50068444 Mon Sep 17 00:00:00 2001 From: catborise Date: Mon, 26 Oct 2026 15:48:37 +0300 Subject: [PATCH] fix(console): the console page connects only to the panel's own proxy - The lite VNC page took the proxy's host, port and path from its link. A link naming another host made the page send that host a fresh signed console token, which opened the VM's console for two minutes. The page now uses only what the server gives it, like the full page. - On a local-socket host, a VM whose console listens on 127.0.0.1 is reached directly instead of being refused for want of an SSH tunnel. - A shut-off VM's console says that the VM is not running instead of failing with no reason. --- console/novncd | 14 +++++----- console/templates/console-vnc-lite.html | 13 +++++----- console/tests/test_views.py | 34 +++++++++++++++++++++++++ console/views.py | 7 ++++- 4 files changed, 54 insertions(+), 14 deletions(-) diff --git a/console/novncd b/console/novncd index c4f3a9a1..b8c81006 100755 --- a/console/novncd +++ b/console/novncd @@ -182,19 +182,22 @@ class CompatibilityMixIn(object): cnx_debug_msg += "- console_socket : '%s'\n" % console_socket logging.debug(cnx_debug_msg) + tunnel = None if console_socket and conntype == CONN_SOCKET: # Local socket on local host self.msg("Try to open local socket %s" % console_socket) tsock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) tsock.connect(console_socket) + elif conntype == CONN_SOCKET and re.match(r"^127\.", console_host): + # libvirt runs on this host: its loopback is ours + self.msg("connecting to: %s:%s" % (console_host, console_port)) + tsock = socket_factory(console_host, console_port, connect=True) elif console_socket or re.match(r"^127\.", console_host): # Need tunnel to physical host if conntype != CONN_SSH: - self.msg( - "Need a tunnel to access console but can't mount " - + "one because it's not a SSH host" - ) - raise Exception(self.msg) + message = "Need a tunnel to access console but can't mount one because it's not a SSH host" + self.msg(message) + raise Exception(message) try: # generate a string with all placeholders to avoid TypeErrors # in sprintf @@ -231,7 +234,6 @@ class CompatibilityMixIn(object): # Direct access self.msg("connecting to: %s:%s" % (connhost, console_port)) tsock = socket_factory(connhost, console_port, connect=True) - tunnel = None if self.verbose and not daemon: print(self.traffic_legend) diff --git a/console/templates/console-vnc-lite.html b/console/templates/console-vnc-lite.html index 6de2555f..c42c8c7c 100755 --- a/console/templates/console-vnc-lite.html +++ b/console/templates/console-vnc-lite.html @@ -10,7 +10,7 @@ noVNC is licensed under the MPL 2.0 (see LICENSE.txt) This file is licensed under the 2-Clause BSD license (see LICENSE.txt). Connect parameters are provided in query string: - http://example.com/?host=HOST&port=PORT&scale=true + http://example.com/?scale=true (the proxy host, port and path come from the server) --> WebVirtCloud - noVNC - Lite @@ -265,10 +265,10 @@ bindKeyAction('ctrlaltf' + (i + 1), () => sendCtrlAltFN(i)); } - // Read parameters specified in the URL query string - // By default, use the host and port of server that served this file - const host = readQueryVariable('host', '{{ ws_host|escapejs }}'); - let port = readQueryVariable('port', '{{ ws_port|escapejs }}'); + // The proxy the server names, never one from the link: the page sends + // it a console token + const host = '{{ ws_host|escapejs }}'; + let port = '{{ ws_port|escapejs }}'; {% if perms.instances.passwordless_console and console_passwd %} const password = '{{ console_passwd|escapejs }}'; @@ -276,8 +276,7 @@ const password = readQueryVariable('password'); {% endif %} - //const path = readQueryVariable('path', 'websockify'); - const path = readQueryVariable('path', '{{ ws_path|escapejs }}'); + const path = '{{ ws_path|escapejs }}'; // | | | | | | // | | | Connect | | | diff --git a/console/tests/test_views.py b/console/tests/test_views.py index bc08d0bf..03dcd21d 100644 --- a/console/tests/test_views.py +++ b/console/tests/test_views.py @@ -229,6 +229,26 @@ class ConsoleViewsTestCase(TestCase): self.assertNotContains(response, "readQueryVariable('token'") self.assertContains(response, f"const token = '{escapejs(signed)}'") + @patch("console.views.wvmInstance") + def test_a_shut_off_vm_says_so(self, mock_wvm): + mock_wvm.return_value.get_console_type.return_value = "vnc" + mock_wvm.return_value.instance.isActive.return_value = 0 + self.client.force_login(self.alice) + response = self.client.get(reverse("console") + f"?token={self.token}") + self.assertContains(response, "The VM is not running") + + @patch("console.views.wvmInstance") + def test_the_lite_console_connects_only_to_the_servers_proxy(self, mock_wvm): + # a link naming another host would send it the signed console token + mock_wvm.return_value.get_console_type.return_value = "vnc" + mock_wvm.return_value.get_console_websocket_port.return_value = None + self.client.force_login(self.alice) + response = self.client.get(reverse("console") + f"?token={self.token}&host=attacker.example&port=443&path=x") + self.assertNotContains(response, "attacker.example") + for name in ("host", "port", "path"): + self.assertNotContains(response, f"readQueryVariable('{name}'") + self.assertContains(response, "const host = 'testserver';") + @patch("console.views.wvmInstance") def test_console_token_cookie_flags(self, mock_wvm): # The token cookie is only read by novncd, never by JS. @@ -539,6 +559,20 @@ class NovncdDaemonLogicTestCase(TestCase): mock_get_info.assert_called_once_with("1-uuid-from-query") socket_factory.assert_called_once_with("host2", 5901, connect=True) + @patch.object(novncd_mod, "get_connection_infos") + def test_a_local_socket_host_reaches_a_console_on_its_loopback(self, mock_get_info): + # libvirt on the panel's host, the VM's console listening on 127.0.0.1 + mock_get_info.return_value = ("localhost", None, "", novncd_mod.CONN_SOCKET, "127.0.0.1", 5902, None) + handler = self.CompatibilityMixIn() + handler.headers = {"cookie": "token=1-uuid"} + handler.path = "/novncd/" + handler.msg = MagicMock() + handler.do_proxy = MagicMock() + handler.verbose = False + socket_factory = MagicMock() + handler._new_client(daemon=False, socket_factory=socket_factory) + socket_factory.assert_called_once_with("127.0.0.1", 5902, connect=True) + @patch.object(novncd_mod, "get_connection_infos") def test_compatibility_mixin_missing_token_safe_exit(self, mock_get_info): handler = self.CompatibilityMixIn() diff --git a/console/views.py b/console/views.py index 4dc00c5c..e5372e39 100644 --- a/console/views.py +++ b/console/views.py @@ -105,7 +105,9 @@ def console(request): console_type = conn.get_console_type() console_websocket_port = conn.get_console_websocket_port() console_passwd = conn.get_console_passwd() + running = conn.instance.isActive() except (libvirtError, ValueError, IndexError, Instance.DoesNotExist): + running = False console_type = None console_websocket_port = None console_passwd = None @@ -124,7 +126,10 @@ def console(request): if ws_path: ws_path = ws_path.strip("/") + "/" if ws_path.strip("/") else "" - if console_type == "vnc": + if console_type and not running: # a shut-off VM has no console to connect to + console_error = _("The VM is not running: start it to open its console.") + response = render(request, "console-vnc-lite.html", locals()) + elif console_type == "vnc": console_page = "console-vnc-" + view_type + ".html" response = render(request, console_page, locals()) elif console_type == "pty" and not settings.SERIAL_CONSOLE_ENABLED: