1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-11 04:36:48 +00:00

Merge branch 'fix/wave0-xss' into wave0-integration

This commit is contained in:
catborise 2026-10-02 22:14:29 +03:00
commit 3df2e68431
8 changed files with 140 additions and 29 deletions

View file

@ -967,7 +967,7 @@
$.getJSON(get_vol_url, function (data) {
$("#image-control").find('option').remove();
$.each(data['vols'], function(i, item) {
$("#image-control").append('<option value=' + item +'>' + item + '</option>');
$("#image-control").append($("<option>").val(item).text(item));
});
$('#image-control').multiselect('rebuild');
});
@ -978,7 +978,7 @@
$.getJSON(get_vol_url, function (data) {
$("#template").find('option').remove();
$.each(data['vols'], function(i, item) {
$("#template").append('<option value=' + item +'>' + item + '</option>');
$("#template").append($("<option>").val(item).text(item));
});
});
$("#template").removeAttr("disabled");
@ -993,7 +993,7 @@
$.getJSON(get_diskBus_url, function (data) {
$("#bus" + dev_idx).find('option').remove();
$.each(data['bus'], function(i, item) {
$("#bus" + dev_idx).append('<option value=' + item +'>' + item + '</option>');
$("#bus" + dev_idx).append($("<option>").val(item).text(item));
});
});
}

View file

@ -150,13 +150,16 @@
$("#vols").removeAttr("disabled");
$.each(data['vols'], function(i, item) {
$("#vols").append('<option value=' + item +'>' + item + '</option>');
$("#vols").append($("<option>").val(item).text(item));
})
});
var sto_drop = document.getElementById('select_storage');
sto_drop.value = pool;
sto_drop.innerHTML = pool + "<span class=\"caret\"></span>";
sto_drop.textContent = pool;
var caret = document.createElement("span");
caret.className = "caret";
sto_drop.appendChild(caret);
var sto_input = document.getElementById('selected_storage');
sto_input.setAttribute('value', pool);

View file

@ -105,15 +105,16 @@
// TODO
logurl = "{% url 'vm_logs' 1 %}".replace(1, vname);
$.getJSON(logurl, function(data) {
var logs = "";
$.each(data, function(id) {
row = data[id];
// console.log(row);
logs += '<tr><td style="width:150px">'+row['date']+'</td>';
logs += '<td>'+row['user']+'</td>';
logs += '<td>'+row['message']+'</td></tr>';
// Log messages contain user-entered names (snapshots, disks, clones);
// insert them as text, never as HTML.
var tbody = $("#logs_table > tbody").empty();
$.each(data, function(id, row) {
$("<tr>")
.append($("<td>").css("width", "150px").text(row['date']))
.append($("<td>").text(row['user']))
.append($("<td>").text(row['message']))
.appendTo(tbody);
});
$("#logs_table > tbody").html(logs);
});
}
</script>

111
instances/test_xss.py Normal file
View file

@ -0,0 +1,111 @@
"""
Regression tests for ROADMAP Wave 0 item 3 (S-06, S-07): JSON endpoints must
not be served as text/html (reflected XSS), and templates must not build HTML
by concatenating server data that contains user-controlled names (stored XSS).
"""
from pathlib import Path
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from accounts.models import UserInstance, UserSSHKey
from computes.models import Compute
from instances.models import Instance
ROOT = Path(__file__).resolve().parent.parent
PAYLOAD = "<script>alert(1)</script>"
class JsonEndpointContentTypeTestCase(TestCase):
def setUp(self):
User = get_user_model()
self.superuser = User.objects.create_superuser(
username="xss_super", email="xss_super@example.com", password="password"
)
self.compute = Compute.objects.create(
name="xss-compute", hostname="127.0.0.1", login="root", password="", type=1
)
self.instance = Instance.objects.create(
compute=self.compute,
name="xss-vm",
uuid="99999999-8888-7777-6666-555555555555",
)
UserInstance.objects.create(instance=self.instance, user=self.superuser)
UserSSHKey.objects.create(
user=self.superuser,
keyname="k",
keypublic=f"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI {PAYLOAD}",
)
self.client.force_login(self.superuser)
def assertJson(self, response):
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "application/json")
def test_guess_mac_address_reflects_vname_as_json(self):
res = self.client.get(
reverse("instances:guess_mac_address", args=["<img src=x onerror=alert(1)>"])
)
self.assertJson(res)
def test_random_mac_address_is_json(self):
self.assertJson(self.client.get(reverse("instances:random_mac_address")))
def test_guess_clone_name_is_json(self):
self.assertJson(self.client.get(reverse("instances:guess_clone_name")))
def test_sshkeys_is_json(self):
self.assertJson(
self.client.get(reverse("instances:sshkeys", args=[self.instance.id]))
)
def test_sshkeys_plain_is_text_plain(self):
res = self.client.get(
reverse("instances:sshkeys", args=[self.instance.id]) + "?plain=true"
)
self.assertEqual(res.status_code, 200)
self.assertTrue(res["Content-Type"].startswith("text/plain"))
def test_storage_volumes_is_json(self):
with patch("storages.views.wvmStorage") as mock_storage:
mock_storage.return_value.get_volumes.return_value = [PAYLOAD]
res = self.client.get(
reverse("volumes", args=[self.compute.id, "default"])
)
self.assertJson(res)
def test_datasource_metadata_is_json(self):
self.assertJson(
self.client.get(reverse("ds_openstack_metadata", args=["latest"]))
)
def test_vm_logs_is_json(self):
self.assertJson(self.client.get(reverse("vm_logs", args=[self.instance.name])))
class TemplateHtmlConcatenationTestCase(TestCase):
"""
The log table and the volume dropdown are filled by JavaScript from JSON
that contains user-entered names. They must use DOM APIs (.text()/.val())
instead of concatenating the values into an HTML string.
"""
def _read(self, relative):
return (ROOT / relative).read_text()
def test_logs_table_does_not_concatenate_log_fields_into_html(self):
src = self._read("instances/templates/instances/stats_tab.html")
for field in ("date", "user", "message"):
self.assertNotIn(f"+row['{field}']+", src)
self.assertNotIn('$("#logs_table > tbody").html(', src)
def test_volume_dropdown_does_not_concatenate_volume_names_into_html(self):
src = self._read("instances/templates/instance.html")
self.assertNotIn("'<option value=' + item", src)
self.assertNotIn('pool + "<span', src)
def test_create_wizard_volume_lists_do_not_concatenate_volume_names_into_html(self):
src = self._read("instances/templates/create_instance_w2.html")
self.assertNotIn("'<option value=' + item", src)

View file

@ -261,13 +261,13 @@ def guess_mac_address(request, vname):
if not mac:
mac = utils.get_random_mac_address()
data["mac"] = mac
return HttpResponse(json.dumps(data))
return JsonResponse(data)
def random_mac_address(request):
data = dict()
data["mac"] = utils.get_random_mac_address()
return HttpResponse(json.dumps(data))
return JsonResponse(data)
def guess_clone_name(request):
@ -284,8 +284,8 @@ def guess_clone_name(request):
fqdn = line.split(" ")[1]
hostname = fqdn.split(".")[0]
if hostname.startswith(prefix) and hostname not in instance_names:
return HttpResponse(json.dumps({"name": hostname}))
return HttpResponse(json.dumps({}))
return JsonResponse({"name": hostname})
return JsonResponse({})
def check_instance(request, vname):
@ -313,9 +313,8 @@ def sshkeys(request, pk):
if request.GET.get("plain", ""):
response = "\n".join(instance_keys)
response += "\n"
else:
response = json.dumps(instance_keys)
return HttpResponse(response)
return HttpResponse(response, content_type="text/plain; charset=utf-8")
return JsonResponse(instance_keys, safe=False)
def get_safe_redirect(request, default=None):