mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-09 11:46:49 +00:00
The VM page's thirteen inline script blocks and its inline handlers move to static files: instance_page.js (tabs, OS details, clone names and MACs, Add disk dialog, boot order, host devices, migrate plan, console window, switches that send their form), instance_stats.js (charts and the Logs view) and instance_status.js (the reload when the VM's state changes). Their values and messages come as data attributes of the elements they belong to, page-wide ones on #vm-summary; a form marked data-please-wait shows the please-wait dialog. Three blocks that did nothing are dropped: popovers (the page has none), the video model preselection (the server selects it) and the delete form's poll stop (that form is on the destroy page). The HTML sink inventory now also reads the app's own scripts.
168 lines
7.1 KiB
Python
168 lines
7.1 KiB
Python
"""
|
|
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).
|
|
"""
|
|
import re
|
|
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.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_compute_json_endpoints_are_json(self):
|
|
urls = [
|
|
reverse("compute_graph", args=[self.compute.id]),
|
|
reverse("machines", args=[self.compute.id, "x86_64"]),
|
|
reverse("buses", args=[self.compute.id, "x86_64", "q35", "disk"]),
|
|
]
|
|
with patch("computes.views.ComputeManager") as mock_mgr:
|
|
for method in ("compute_graph", "get_machine_types", "get_disk_buses"):
|
|
getattr(mock_mgr.return_value, method).return_value = "{}"
|
|
for url in urls:
|
|
with self.subTest(url=url):
|
|
self.assertJson(self.client.get(url))
|
|
|
|
def test_vm_logs_is_json(self):
|
|
self.assertJson(self.client.get(reverse("vm_logs", args=[self.instance.pk])))
|
|
|
|
|
|
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("static/js/instance_stats.js")
|
|
for field in ("date", "user", "message"):
|
|
self.assertNotIn(f"+row['{field}']+", src)
|
|
self.assertNotIn(".html(", src)
|
|
|
|
def test_volume_dropdown_does_not_concatenate_volume_names_into_html(self):
|
|
src = self._read("static/js/instance_page.js")
|
|
self.assertNotIn("'<option value=' + item", src)
|
|
self.assertNotIn('pool + "<span', src)
|
|
|
|
def test_create_wizard_chipset_list_does_not_concatenate_into_html(self):
|
|
src = self._read("instances/templates/create_instance_w1.html")
|
|
self.assertNotIn("""append('<option value="' + item""", 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)
|
|
|
|
|
|
# Every line of a template or of the app's own scripts that builds HTML from
|
|
# JavaScript must be listed here with a reason; a new one fails the test and
|
|
# has to be reviewed (use .text() / .val() / DOM APIs for server data instead).
|
|
HTML_SINK = re.compile(r"""\.html\(|innerHTML\s*=|append\(\s*['"]<""")
|
|
# the libraries kept as published, not the app's scripts
|
|
VENDORED_SCRIPTS = {"jquery.js", "js.cookie.js", "socket.io.js"}
|
|
ALLOWED_HTML_SINKS = {
|
|
("static/js/instance_page.js", "hostDevices.innerHTML = html;"): "the panel's own fragment, host_devices_form.html, escaped by Django",
|
|
("static/js/instance_page.js", "migratePlan.innerHTML = html;"): "the panel's own fragment, migrate_plan.html, escaped by Django",
|
|
("operations/static/operations/job_panel.js", "element.innerHTML = html;"): "built by the panel, every job value through esc()",
|
|
}
|
|
|
|
|
|
class TemplateHtmlSinkInventoryTestCase(TestCase):
|
|
def test_every_html_sink_in_templates_is_reviewed(self):
|
|
found = set()
|
|
for path in ROOT.glob("*/templates/**/*.html"):
|
|
rel = str(path.relative_to(ROOT))
|
|
for line in path.read_text().splitlines():
|
|
if HTML_SINK.search(line):
|
|
found.add((rel, line.strip()))
|
|
templates_root = ROOT / "templates"
|
|
for path in templates_root.glob("**/*.html"):
|
|
rel = str(path.relative_to(ROOT))
|
|
for line in path.read_text().splitlines():
|
|
if HTML_SINK.search(line):
|
|
found.add((rel, line.strip()))
|
|
scripts = [*(ROOT / "static/js").glob("*.js"), *ROOT.glob("*/static/**/*.js")]
|
|
for path in scripts:
|
|
if path.name in VENDORED_SCRIPTS or path.name.endswith(".min.js"):
|
|
continue
|
|
rel = str(path.relative_to(ROOT))
|
|
for line in path.read_text().splitlines():
|
|
if HTML_SINK.search(line):
|
|
found.add((rel, line.strip()))
|
|
self.assertEqual(sorted(found - set(ALLOWED_HTML_SINKS)), [], "unreviewed HTML sinks")
|
|
self.assertEqual(sorted(set(ALLOWED_HTML_SINKS) - found), [], "stale allowlist entries")
|