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:
parent
3d4accf1b4
commit
f19c30840e
6 changed files with 102 additions and 35 deletions
|
|
@ -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" %}');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("'", 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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue