1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00

fix(console): prevent DOM-based XSS in console views and escape JS strings

- Validate and coerce console query parameters (view_only, scale, resize_session, clip_viewport) to strict booleans via to_bool in console/views.py
- Sanitize view parameter to strictly 'lite' or 'full'
- Apply escapejs filter to all JS string interpolations in console-vnc-full.html, console-vnc-lite.html, console-spice-lite.html, and console-xterm.html
- Replace unsafe '| safe' filter in console-spice-lite.html with escapejs
- Add unit tests for XSS query param mitigation and escapejs character encoding
This commit is contained in:
catborise 2026-09-26 19:28:22 +03:00
parent 3d4accf1b4
commit f19c30840e
6 changed files with 102 additions and 35 deletions

View file

@ -65,7 +65,7 @@
// By default, use the host and port of server that served this file
// host = spice_query_var('host', window.location.hostname);
host = '{{ ws_host| safe }}';
host = '{{ ws_host|escapejs }}';
// Note that using the web server port only makes sense
// if your web server has a reverse proxy to relay the WebSocket
@ -80,7 +80,7 @@
}
}
//port = spice_query_var('port', default_port);
port = '{{ ws_port| safe }}';
port = '{{ ws_port|escapejs }}';
if (window.location.protocol == 'https:') {
scheme = "wss://";
}
@ -93,17 +93,16 @@
}
if (password === undefined) {
{% if perms.instances.passwordless_console %}
password = '{{ console_passwd }}';
{% if perms.instances.passwordless_console and console_passwd %}
password = '{{ console_passwd|escapejs }}';
{% else %}
password = prompt('{% trans "Password" %}');
{% endif %}
//password = '{{ console_passwd | safe }}';
}
if (password === 'None') password = '';
//var path = spice_query_var('path', 'websockify');
var path = spice_query_var('path', '{{ ws_path }}');
var path = spice_query_var('path', '{{ ws_path|escapejs }}');
if ((!host) || (!port)) {
console.log('{% trans "must specify host and port in URL" %}');

View file

@ -176,24 +176,24 @@
bindKeyAction('ctrlaltf' + (i + 1), () => sendCtrlAltFN(i));
}
let fullPath = '{{ ws_path }}';
const token = '{{ token }}';
let fullPath = '{{ ws_path|escapejs }}';
const token = '{{ token|escapejs }}';
if (token) {
fullPath += (fullPath.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(token);
}
const defaults = {
autoconnect: true,
resize: '{{ scale }}' === 'true' ? 'scale' : 'off',
view_only: {{ view_only }},
view_clip: {{ clip_viewport }},
resize: {{ scale|yesno:"'scale','off'" }},
view_only: {{ view_only|yesno:"true,false" }},
view_clip: {{ clip_viewport|yesno:"true,false" }},
{% if perms.instances.passwordless_console and console_passwd %}
password: '{{ console_passwd }}',
password: '{{ console_passwd|escapejs }}',
{% endif %}
};
const mandatory = {
host: '{{ ws_host }}',
host: '{{ ws_host|escapejs }}',
port: {{ ws_port }},
path: fullPath,
};

View file

@ -260,17 +260,17 @@
// 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 }}');
let port = readQueryVariable('port', '{{ ws_port }}');
const host = readQueryVariable('host', '{{ ws_host|escapejs }}');
let port = readQueryVariable('port', '{{ ws_port|escapejs }}');
{% if perms.instances.passwordless_console %}
const password = '{{ console_passwd }}';
{% if perms.instances.passwordless_console and console_passwd %}
const password = '{{ console_passwd|escapejs }}';
{% else %}
const password = readQueryVariable('password');
{% endif %}
//const path = readQueryVariable('path', 'websockify');
const path = readQueryVariable('path', '{{ ws_path }}');
const path = readQueryVariable('path', '{{ ws_path|escapejs }}');
// | | | | | |
// | | | Connect | | |
@ -294,7 +294,7 @@
} else {
url += '/';
}
const token = readQueryVariable('token', '{{ token }}');
const token = readQueryVariable('token', '{{ token|escapejs }}');
if (token) {
url += (url.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(token);
}
@ -310,10 +310,10 @@
rfb.addEventListener("desktopname", updateDesktopName);
// Set parameters that can be changed on an active connection
rfb.scaleViewport = {{ scale }};
rfb.viewOnly = {{ view_only }};
rfb.resizeSession = {{ resize_session }};
rfb.clipViewport = {{ clip_viewport }};
rfb.scaleViewport = {{ scale|yesno:"true,false" }};
rfb.viewOnly = {{ view_only|yesno:"true,false" }};
rfb.resizeSession = {{ resize_session|yesno:"true,false" }};
rfb.clipViewport = {{ clip_viewport|yesno:"true,false" }};
</script>
{% endblock %}

View file

@ -22,9 +22,9 @@
<script crossorigin="anonymous">
Terminal.applyAddon(fit);
let host = readQueryVariable('host', '{{ socketio_host }}');
let port = readQueryVariable('port', '{{ socketio_port }}');
let path = readQueryVariable('path', '{{ socketio_path }}');
let host = readQueryVariable('host', '{{ socketio_host|escapejs }}');
let port = readQueryVariable('port', '{{ socketio_port|escapejs }}');
let path = readQueryVariable('path', '{{ socketio_path|escapejs }}');
// Build the websocket URL used to connect

View file

@ -236,6 +236,56 @@ class ConsoleViewsTestCase(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["ws_host"], "panel.example.com")
@patch("console.views.wvmInstance")
def test_console_xss_protection_query_parameters(self, mock_wvm):
mock_conn = MagicMock()
mock_conn.get_console_type.return_value = "vnc"
mock_conn.get_console_websocket_port.return_value = None
mock_wvm.return_value = mock_conn
self.client.force_login(self.admin_user)
# Attempt XSS injection via query parameters
url = (
reverse("console")
+ f"?token={self.token}&view=lite&view_only=alert('xss')&scale=true&clip_viewport=1"
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["view_only"], False)
self.assertEqual(response.context["scale"], True)
self.assertEqual(response.context["clip_viewport"], True)
content = response.content.decode("utf-8")
self.assertNotIn("alert('xss')", content)
self.assertIn("rfb.viewOnly = false;", content)
self.assertIn("rfb.scaleViewport = true;", content)
@patch("console.views.wvmInstance")
def test_console_password_escapejs_escaping(self, mock_wvm):
mock_conn = MagicMock()
mock_conn.get_console_type.return_value = "vnc"
mock_conn.get_console_websocket_port.return_value = None
mock_conn.get_console_passwd.return_value = "O'Brian<script>&123"
mock_wvm.return_value = mock_conn
self.client.force_login(self.admin_user)
# Test Lite mode
url_lite = reverse("console") + f"?token={self.token}&view=lite"
resp_lite = self.client.get(url_lite)
self.assertEqual(resp_lite.status_code, 200)
content_lite = resp_lite.content.decode("utf-8")
self.assertIn(r"O\u0027Brian", content_lite)
self.assertNotIn("&#39;", content_lite)
# Test Full mode
url_full = reverse("console") + f"?token={self.token}&view=full"
resp_full = self.client.get(url_full)
self.assertEqual(resp_full.status_code, 200)
content_full = resp_full.content.decode("utf-8")
self.assertIn(r"O\u0027Brian", content_full)
@patch("console.views.wvmInstance")
def test_console_libvirt_error_fallback(self, mock_wvm):
mock_wvm.side_effect = libvirtError("Connection to libvirtd failed")

View file

@ -12,6 +12,12 @@ from libvirt import libvirtError
from vrtManager.instance import wvmInstance
def to_bool(val, default=False):
if val is None:
return default
return str(val).strip().lower() in ("true", "1", "yes")
def console(request):
"""
:param request:
@ -20,18 +26,30 @@ def console(request):
console_error = None
token = ""
view_type = "lite"
view_only = app_settings.CONSOLE_VIEW_ONLY.lower()
scale = app_settings.CONSOLE_SCALE.lower()
resize_session = app_settings.CONSOLE_RESIZE_SESSION.lower()
clip_viewport = app_settings.CONSOLE_CLIP_VIEWPORT.lower()
view_only = to_bool(app_settings.CONSOLE_VIEW_ONLY, default=False)
scale = to_bool(app_settings.CONSOLE_SCALE, default=False)
resize_session = to_bool(app_settings.CONSOLE_RESIZE_SESSION, default=False)
clip_viewport = to_bool(app_settings.CONSOLE_CLIP_VIEWPORT, default=False)
if request.method == "GET":
token = request.GET.get("token", token)
view_type = request.GET.get("view", view_type)
view_only = request.GET.get("view_only", view_only)
scale = request.GET.get("scale", scale)
resize_session = request.GET.get("resize_session", resize_session)
clip_viewport = request.GET.get("clip_viewport", clip_viewport)
view_type = (
"full"
if str(request.GET.get("view", view_type)).strip().lower() == "full"
else "lite"
)
if "view_only" in request.GET:
view_only = to_bool(request.GET.get("view_only"), default=view_only)
if "scale" in request.GET:
scale = to_bool(request.GET.get("scale"), default=scale)
if "resize_session" in request.GET:
resize_session = to_bool(
request.GET.get("resize_session"), default=resize_session
)
if "clip_viewport" in request.GET:
clip_viewport = to_bool(
request.GET.get("clip_viewport"), default=clip_viewport
)
try:
temptoken = token.split("-", 1)