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

fix(instances): restrict disk views to superusers and validate disk input

Disk and media mutation views (add_new_vol, add_existing_vol, edit_volume,
delete_vol, detach_vol, add_cdrom, detach_cdrom, mount_iso, unmount_iso)
were reachable by any owner with is_change, although the UI only shows the
Disk tab to superusers. An ordinary user could delete any volume on the
compute or attach arbitrary host files to their VM.

- Require superuser for all disk/media views, matching the UI.
- delete_vol resolves the volume from the VM's own disk XML by dev and
  ignores the posted storage/name.
- Validate bus/cache/io/discard/detect_zeroes against the hypervisor's
  lists, and format/serial/volume name against strict patterns.
- add_existing_vol only accepts volumes that exist in the selected pool.
- edit_volume only accepts a dev attached to the VM.
- vrtManager: escape all values interpolated into disk XML and pass dev
  as an XPath variable in detach_disk/edit_disk.
This commit is contained in:
catborise 2026-10-02 17:11:12 +03:00
parent b1ab1d47fa
commit 8a3ee13b41
5 changed files with 573 additions and 179 deletions

View file

@ -0,0 +1,249 @@
"""
Regression tests for ROADMAP Wave 0 item 1 (S-02, S-03, S-04):
disk and media mutation views must be superuser-only (matching the UI, where
the Disk tab is only rendered for superusers), and even for superusers the
views must not act on volumes or option values taken blindly from the client.
"""
from unittest.mock import MagicMock, patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from accounts.models import UserInstance
from computes.models import Compute
from instances.models import Instance
DISK_ENDPOINTS = [
("add_new_vol", []),
("add_existing_vol", []),
("edit_volume", []),
("delete_vol", []),
("detach_vol", []),
("add_cdrom", []),
("detach_cdrom", ["hda"]),
("mount_iso", []),
("unmount_iso", []),
]
VM_DISK = {
"dev": "vda",
"bus": "virtio",
"image": "vm-disk.qcow2",
"storage": "default",
"path": "/var/lib/libvirt/images/vm-disk.qcow2",
"format": "qcow2",
}
class DiskViewsTenantIsolationTestCase(TestCase):
def setUp(self):
User = get_user_model()
self.superuser = User.objects.create_superuser(
username="disk_super", email="disk_super@example.com", password="password"
)
self.change_user = User.objects.create_user(
username="disk_change_user", password="password"
)
self.staff_change_user = User.objects.create_user(
username="disk_staff_change_user", password="password", is_staff=True
)
self.compute = Compute.objects.create(
name="disk-compute", hostname="127.0.0.1", login="root", password="", type=1
)
self.instance = Instance.objects.create(
compute=self.compute,
name="disk-test-vm",
uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
)
for user in (self.change_user, self.staff_change_user):
UserInstance.objects.create(
instance=self.instance, user=user, is_change=True, is_delete=False
)
self.referer = f"/instances/{self.instance.id}/"
def _url(self, name, extra=()):
return reverse(f"instances:{name}", args=[self.instance.id, *extra])
def _post(self, name, data, extra=()):
return self.client.post(self._url(name, extra), data, HTTP_REFERER=self.referer)
def test_owner_with_is_change_cannot_mutate_disks(self):
for user in (self.change_user, self.staff_change_user):
self.client.force_login(user)
with patch("instances.models.wvmInstance") as mock_wvm, patch(
"instances.views.wvmStorage"
) as mock_storage, patch("instances.views.wvmCreate") as mock_create:
for name, extra in DISK_ENDPOINTS:
res = self._post(
name,
{"storage": "default", "name": "victim.qcow2", "dev": "vda"},
extra,
)
self.assertEqual(
res.status_code, 403, f"{user.username} POST {name} should be 403"
)
mock_wvm.assert_not_called()
mock_storage.assert_not_called()
mock_create.assert_not_called()
def test_delete_vol_deletes_the_volume_attached_to_dev_not_the_posted_one(self):
self.client.force_login(self.superuser)
with patch("instances.models.wvmInstance") as mock_wvm, patch(
"instances.views.wvmStorage"
) as mock_storage:
mock_wvm.return_value.get_disk_devices.return_value = [VM_DISK]
res = self._post(
"delete_vol",
{"storage": "other-pool", "name": "victim.qcow2", "dev": "vda"},
)
self.assertEqual(res.status_code, 302)
mock_wvm.return_value.detach_disk.assert_called_once_with("vda")
self.assertEqual(mock_storage.call_args[0][-1], "default")
mock_storage.return_value.del_volume.assert_called_once_with("vm-disk.qcow2")
def test_delete_vol_refuses_dev_not_attached_to_vm(self):
self.client.force_login(self.superuser)
with patch("instances.models.wvmInstance") as mock_wvm, patch(
"instances.views.wvmStorage"
) as mock_storage:
mock_wvm.return_value.get_disk_devices.return_value = [VM_DISK]
res = self._post(
"delete_vol",
{"storage": "default", "name": "victim.qcow2", "dev": "vdz"},
)
self.assertEqual(res.status_code, 302)
mock_wvm.return_value.detach_disk.assert_not_called()
mock_storage.return_value.del_volume.assert_not_called()
def test_edit_volume_rejects_unknown_cache_mode(self):
self.client.force_login(self.superuser)
with patch("instances.models.wvmInstance") as mock_wvm:
proxy = mock_wvm.return_value
proxy.get_disk_devices.return_value = [VM_DISK]
proxy.get_media_devices.return_value = []
proxy.get_disk_bus_types.return_value = ["virtio", "sata", "scsi"]
proxy.get_cache_modes.return_value = {"default": "", "none": ""}
proxy.get_io_modes.return_value = {"default": "", "native": ""}
proxy.get_discard_modes.return_value = {"default": "", "unmap": ""}
proxy.get_detect_zeroes_modes.return_value = {"default": "", "on": ""}
res = self._post(
"edit_volume",
{
"edit_volume": "1",
"dev": "vda",
"vol_path": VM_DISK["path"],
"vol_bus_old": "virtio",
"vol_bus": "virtio",
"vol_format": "qcow2",
"vol_cache": "none' io='native",
},
)
self.assertEqual(res.status_code, 302)
proxy.edit_disk.assert_not_called()
proxy.attach_disk.assert_not_called()
proxy.detach_disk.assert_not_called()
def test_add_new_vol_rejects_unknown_bus(self):
self.client.force_login(self.superuser)
with patch("instances.models.wvmInstance") as mock_wvm, patch(
"instances.views.wvmCreate"
) as mock_create, patch("instances.views.wvmStorage"):
proxy = mock_wvm.return_value
proxy.get_disk_devices.return_value = [VM_DISK]
proxy.get_media_devices.return_value = []
proxy.get_disk_bus_types.return_value = ["virtio", "sata", "scsi"]
proxy.get_cache_modes.return_value = {"default": "", "none": ""}
res = self._post(
"add_new_vol",
{
"storage": "default",
"name": "new-disk",
"format": "qcow2",
"size": "10",
"bus": "virtio'/><disk",
"cache": "default",
},
)
self.assertEqual(res.status_code, 302)
mock_create.return_value.create_volume.assert_not_called()
proxy.attach_disk.assert_not_called()
def test_add_existing_vol_rejects_volume_not_in_pool(self):
self.client.force_login(self.superuser)
with patch("instances.models.wvmInstance") as mock_wvm, patch(
"instances.views.wvmStorage"
) as mock_storage:
proxy = mock_wvm.return_value
proxy.get_disk_devices.return_value = [VM_DISK]
proxy.get_media_devices.return_value = []
proxy.get_disk_bus_types.return_value = ["virtio", "sata", "scsi"]
proxy.get_cache_modes.return_value = {"default": "", "none": ""}
mock_storage.return_value.get_volumes.return_value = ["vm-disk.qcow2"]
res = self._post(
"add_existing_vol",
{
"selected_storage": "default",
"vols": "../../../etc/shadow",
"bus": "virtio",
"cache": "default",
},
)
self.assertEqual(res.status_code, 302)
proxy.attach_disk.assert_not_called()
def _mock_disk_options(self, proxy):
proxy.get_disk_devices.return_value = [VM_DISK]
proxy.get_media_devices.return_value = []
proxy.get_disk_bus_types.return_value = ["virtio", "sata", "scsi"]
proxy.get_cache_modes.return_value = {"default": "", "none": ""}
proxy.get_io_modes.return_value = {"default": "", "native": ""}
proxy.get_discard_modes.return_value = {"default": "", "unmap": ""}
proxy.get_detect_zeroes_modes.return_value = {"default": "", "on": ""}
def test_add_new_vol_rejects_unsafe_volume_name(self):
self.client.force_login(self.superuser)
with patch("instances.models.wvmInstance") as mock_wvm, patch(
"instances.views.wvmCreate"
) as mock_create, patch("instances.views.wvmStorage"):
self._mock_disk_options(mock_wvm.return_value)
res = self._post(
"add_new_vol",
{
"storage": "default",
"name": "x</name><key>/etc/shadow",
"format": "qcow2",
"size": "10",
"bus": "virtio",
"cache": "default",
},
)
self.assertEqual(res.status_code, 302)
mock_create.return_value.create_volume.assert_not_called()
def test_edit_volume_accepts_valid_options(self):
self.client.force_login(self.superuser)
with patch("instances.models.wvmInstance") as mock_wvm:
proxy = mock_wvm.return_value
self._mock_disk_options(proxy)
res = self._post(
"edit_volume",
{
"edit_volume": "1",
"dev": "vda",
"vol_path": VM_DISK["path"],
"vol_bus_old": "virtio",
"vol_bus": "virtio",
"vol_format": "qcow2",
"vol_serial": "SER-01",
"vol_cache": "none",
"vol_io_mode": "native",
"vol_discard_mode": "unmap",
"vol_detect_zeroes": "on",
},
)
self.assertEqual(res.status_code, 302)
proxy.edit_disk.assert_called_once_with(
"vda", VM_DISK["path"], False, False, "virtio", "SER-01", "qcow2",
"none", "native", "unmap", "on",
)

View file

@ -297,17 +297,18 @@ class InstanceSecurityMutationsTestCase(TestCase):
self.assertEqual(res.status_code, 302)
self.assertIn(f"/instances/{self.instance.id}/#resize", res.url)
def test_subresource_mutation_allowed_with_is_change(self):
# change_user has is_change=True, is_delete=False
def test_disk_mutation_denied_with_is_change(self):
# change_user has is_change=True, is_delete=False. Disk views are
# superuser-only (S-02/S-03/S-04), so is_change alone is not enough.
self.client.force_login(self.change_user)
with patch("instances.models.wvmInstance"), patch("instances.views.wvmStorage"):
# delete_vol uses perm_type="change", so it passes get_instance without 403
with patch("instances.models.wvmInstance"), patch("instances.views.wvmStorage") as mock_storage:
res = self.client.post(
reverse("instances:delete_vol", args=[self.instance.id]),
{"storage": "default", "dev": "vda", "path": "/path"},
HTTP_REFERER=f"/instances/{self.instance.id}/",
)
self.assertEqual(res.status_code, 302)
self.assertEqual(res.status_code, 403)
mock_storage.return_value.del_volume.assert_not_called()
def test_destroy_vm_authorization(self):
# 1. User with is_change=True but is_delete=False gets 403 when trying to destroy VM

View file

@ -392,6 +392,54 @@ def serialize_instance_mutation(func):
return wrapper
DISK_FORMAT_RE = re.compile(r"^[a-z0-9]+$")
DISK_SERIAL_RE = re.compile(r"^[A-Za-z0-9_.+-]*$")
VOLUME_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.+-]*$")
def invalid_disk_options(
instance,
bus=None,
cache=None,
io=None,
discard=None,
zeroes=None,
format=None,
serial=None,
):
"""
Return the names of the given disk options whose values are not allowed.
These values end up in libvirt disk XML, so they must come from the
hypervisor's own lists (or match a strict pattern). None means "not given".
"""
proxy = instance.proxy
checks = {
"bus": (
bus,
lambda v: v in proxy.get_disk_bus_types(instance.arch, instance.machine),
),
"cache": (cache, lambda v: v in proxy.get_cache_modes()),
"io": (io, lambda v: v in proxy.get_io_modes()),
"discard": (discard, lambda v: v in proxy.get_discard_modes()),
"detect_zeroes": (zeroes, lambda v: v in proxy.get_detect_zeroes_modes()),
"format": (format, lambda v: bool(DISK_FORMAT_RE.match(v))),
"serial": (serial, lambda v: bool(DISK_SERIAL_RE.match(v))),
}
return [
name
for name, (value, is_valid) in checks.items()
if value is not None and not is_valid(value)
]
def reject_disk_options(request, invalid):
messages.error(
request,
_("Invalid disk options: %(options)s") % {"options": ", ".join(invalid)},
)
return redirect(request.META.get("HTTP_REFERER") + "#disks")
@require_POST
@serialize_instance_mutation
def poweron(request, pk):
@ -761,135 +809,142 @@ def resize_disk(request, pk):
@require_POST
@superuser_only
@serialize_instance_mutation
def add_new_vol(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
allow_admin_or_not_template = (
request.user.is_superuser or request.user.is_staff or not instance.is_template
media = instance.proxy.get_media_devices()
disks = instance.proxy.get_disk_devices()
storage = request.POST.get("storage", "")
name = request.POST.get("name", "")
format = request.POST.get("format", app_settings.INSTANCE_VOLUME_DEFAULT_FORMAT)
size = request.POST.get("size", 0)
meta_prealloc = True if request.POST.get("meta_prealloc", False) else False
bus = request.POST.get("bus", app_settings.INSTANCE_VOLUME_DEFAULT_BUS)
cache = request.POST.get("cache", app_settings.INSTANCE_VOLUME_DEFAULT_CACHE)
invalid = invalid_disk_options(instance, bus=bus, cache=cache, format=format)
if not VOLUME_NAME_RE.match(name):
invalid.append("name")
if invalid:
return reject_disk_options(request, invalid)
conn_create = wvmCreate(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
)
target_dev = utils.get_new_disk_dev(media, disks, bus)
source = conn_create.create_volume(
storage,
name,
size,
format,
meta_prealloc,
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID),
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID),
)
if allow_admin_or_not_template:
media = instance.proxy.get_media_devices()
disks = instance.proxy.get_disk_devices()
conn_create = wvmCreate(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
)
storage = request.POST.get("storage", "")
name = request.POST.get("name", "")
format = request.POST.get("format", app_settings.INSTANCE_VOLUME_DEFAULT_FORMAT)
size = request.POST.get("size", 0)
meta_prealloc = True if request.POST.get("meta_prealloc", False) else False
bus = request.POST.get("bus", app_settings.INSTANCE_VOLUME_DEFAULT_BUS)
cache = request.POST.get("cache", app_settings.INSTANCE_VOLUME_DEFAULT_CACHE)
target_dev = utils.get_new_disk_dev(media, disks, bus)
conn_pool = wvmStorage(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
storage,
)
source = conn_create.create_volume(
storage,
name,
size,
format,
meta_prealloc,
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID),
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID),
)
pool_type = conn_pool.get_type()
disk_type = conn_pool.get_volume_type(os.path.basename(source))
conn_pool = wvmStorage(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
storage,
)
if pool_type == "rbd":
source_info = conn_pool.get_rbd_source()
else: # add more disk types to handle different pool and disk types
source_info = None
pool_type = conn_pool.get_type()
disk_type = conn_pool.get_volume_type(os.path.basename(source))
if pool_type == "rbd":
source_info = conn_pool.get_rbd_source()
else: # add more disk types to handle different pool and disk types
source_info = None
instance.proxy.attach_disk(
target_dev,
source,
source_info=source_info,
pool_type=pool_type,
disk_type=disk_type,
target_bus=bus,
format_type=format,
cache_mode=cache,
)
msg = _("Attach new disk: %(name)s (%(format)s)") % {
"name": name,
"format": format,
}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
instance.proxy.attach_disk(
target_dev,
source,
source_info=source_info,
pool_type=pool_type,
disk_type=disk_type,
target_bus=bus,
format_type=format,
cache_mode=cache,
)
msg = _("Attach new disk: %(name)s (%(format)s)") % {
"name": name,
"format": format,
}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return redirect(request.META.get("HTTP_REFERER") + "#disks")
@require_POST
@superuser_only
@serialize_instance_mutation
def add_existing_vol(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
allow_admin_or_not_template = (
request.user.is_superuser or request.user.is_staff or not instance.is_template
storage = request.POST.get("selected_storage", "")
name = request.POST.get("vols", "")
bus = request.POST.get("bus", app_settings.INSTANCE_VOLUME_DEFAULT_BUS)
cache = request.POST.get("cache", app_settings.INSTANCE_VOLUME_DEFAULT_CACHE)
invalid = invalid_disk_options(instance, bus=bus, cache=cache)
if invalid:
return reject_disk_options(request, invalid)
media = instance.proxy.get_media_devices()
disks = instance.proxy.get_disk_devices()
conn_create = wvmStorage(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
storage,
)
if allow_admin_or_not_template:
storage = request.POST.get("selected_storage", "")
name = request.POST.get("vols", "")
bus = request.POST.get("bus", app_settings.INSTANCE_VOLUME_DEFAULT_BUS)
cache = request.POST.get("cache", app_settings.INSTANCE_VOLUME_DEFAULT_CACHE)
media = instance.proxy.get_media_devices()
disks = instance.proxy.get_disk_devices()
# Only volumes that really exist in the selected pool; this also rules
# out path traversal through the volume name.
if name not in conn_create.get_volumes():
return reject_disk_options(request, ["vols"])
conn_create = wvmStorage(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
storage,
)
format_type = conn_create.get_volume_format_type(name)
disk_type = conn_create.get_volume_type(name)
pool_type = conn_create.get_type()
if pool_type == "rbd":
source_info = conn_create.get_rbd_source()
path = conn_create.get_source_name()
else:
source_info = None
path = conn_create.get_target_path()
format_type = conn_create.get_volume_format_type(name)
disk_type = conn_create.get_volume_type(name)
pool_type = conn_create.get_type()
if pool_type == "rbd":
source_info = conn_create.get_rbd_source()
path = conn_create.get_source_name()
else:
source_info = None
path = conn_create.get_target_path()
target_dev = utils.get_new_disk_dev(media, disks, bus)
source = f"{path}/{name}"
target_dev = utils.get_new_disk_dev(media, disks, bus)
source = f"{path}/{name}"
instance.proxy.attach_disk(
target_dev,
source,
source_info=source_info,
pool_type=pool_type,
disk_type=disk_type,
target_bus=bus,
format_type=format_type,
cache_mode=cache,
)
msg = _("Attach Existing disk: %(target_dev)s") % {"target_dev": target_dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
instance.proxy.attach_disk(
target_dev,
source,
source_info=source_info,
pool_type=pool_type,
disk_type=disk_type,
target_bus=bus,
format_type=format_type,
cache_mode=cache,
)
msg = _("Attach Existing disk: %(target_dev)s") % {"target_dev": target_dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return redirect(request.META.get("HTTP_REFERER") + "#disks")
@require_POST
@superuser_only
@serialize_instance_mutation
def edit_volume(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
allow_admin_or_not_template = (
request.user.is_superuser or request.user.is_staff or not instance.is_template
)
if "edit_volume" in request.POST and allow_admin_or_not_template:
if "edit_volume" in request.POST:
target_dev = request.POST.get("dev", "")
new_path = request.POST.get("vol_path", "")
@ -910,6 +965,22 @@ def edit_volume(request, pk):
zeroes = request.POST.get(
"vol_detect_zeroes", app_settings.INSTANCE_VOLUME_DEFAULT_DETECT_ZEROES
)
invalid = invalid_disk_options(
instance,
bus=new_bus,
cache=cache,
io=io,
discard=discard,
zeroes=zeroes,
format=format,
serial=serial,
)
if target_dev not in [disk["dev"] for disk in instance.disks]:
invalid.append("dev")
if invalid:
return reject_disk_options(request, invalid)
new_target_dev = utils.get_new_disk_dev(instance.media, instance.disks, new_bus)
if new_bus != bus:
@ -959,122 +1030,113 @@ def edit_volume(request, pk):
@require_POST
@superuser_only
@serialize_instance_mutation
def delete_vol(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
allow_admin_or_not_template = (
request.user.is_superuser or request.user.is_staff or not instance.is_template
)
if allow_admin_or_not_template:
storage = request.POST.get("storage", "")
conn_delete = wvmStorage(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
storage,
dev = request.POST.get("dev", "")
# Resolve the volume from the VM's own XML; never trust the posted
# storage/name, which could point at any volume on the compute.
disk = next((d for d in instance.disks if d["dev"] == dev), None)
if disk is None or not disk["storage"] or not disk["image"]:
messages.error(
request,
_("Disk %(dev)s is not a storage volume of this instance") % {"dev": dev},
)
dev = request.POST.get("dev", "")
path = request.POST.get("path", "")
name = request.POST.get("name", "")
return redirect(request.META.get("HTTP_REFERER") + "#disks")
msg = _("Delete disk: %(dev)s") % {"dev": dev}
instance.proxy.detach_disk(dev)
conn_delete.del_volume(name)
conn_delete = wvmStorage(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
disk["storage"],
)
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
msg = _("Delete disk: %(dev)s") % {"dev": dev}
instance.proxy.detach_disk(dev)
conn_delete.del_volume(disk["image"])
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return redirect(request.META.get("HTTP_REFERER") + "#disks")
@require_POST
@superuser_only
@serialize_instance_mutation
def detach_vol(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
allow_admin_or_not_template = (
request.user.is_superuser or request.user.is_staff or not instance.is_template
)
if allow_admin_or_not_template:
dev = request.POST.get("dev", "")
path = request.POST.get("path", "")
instance.proxy.detach_disk(dev)
msg = _("Detach disk: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
dev = request.POST.get("dev", "")
instance.proxy.detach_disk(dev)
msg = _("Detach disk: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return redirect(request.META.get("HTTP_REFERER") + "#disks")
@require_POST
@superuser_only
@serialize_instance_mutation
def add_cdrom(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
allow_admin_or_not_template = (
request.user.is_superuser or request.user.is_staff or not instance.is_template
bus = request.POST.get("bus", "ide" if instance.machine == "pc" else "sata")
invalid = invalid_disk_options(instance, bus=bus)
if invalid:
return reject_disk_options(request, invalid)
target = utils.get_new_disk_dev(instance.media, instance.disks, bus)
instance.proxy.attach_disk(
target,
"",
disk_device="cdrom",
cache_mode="none",
target_bus=bus,
readonly=True,
)
if allow_admin_or_not_template:
bus = request.POST.get("bus", "ide" if instance.machine == "pc" else "sata")
target = utils.get_new_disk_dev(instance.media, instance.disks, bus)
instance.proxy.attach_disk(
target,
"",
disk_device="cdrom",
cache_mode="none",
target_bus=bus,
readonly=True,
)
msg = _("Add CD-ROM: %(target)s") % {"target": target}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
msg = _("Add CD-ROM: %(target)s") % {"target": target}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return redirect(request.META.get("HTTP_REFERER") + "#disks")
@require_POST
@superuser_only
@serialize_instance_mutation
def detach_cdrom(request, pk, dev):
instance = get_instance(request.user, pk, perm_type="change")
allow_admin_or_not_template = (
request.user.is_superuser or request.user.is_staff or not instance.is_template
)
if allow_admin_or_not_template:
# dev = request.POST.get('detach_cdrom', '')
instance.proxy.detach_disk(dev)
msg = _("Detach CD-ROM: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
# dev = request.POST.get('detach_cdrom', '')
instance.proxy.detach_disk(dev)
msg = _("Detach CD-ROM: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return redirect(request.META.get("HTTP_REFERER") + "#disks")
@require_POST
@superuser_only
@serialize_instance_mutation
def unmount_iso(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
allow_admin_or_not_template = (
request.user.is_superuser or request.user.is_staff or not instance.is_template
)
if allow_admin_or_not_template:
image = request.POST.get("path", "")
dev = request.POST.get("umount_iso", "")
instance.proxy.umount_iso(dev, image)
msg = _("Mount media: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
image = request.POST.get("path", "")
dev = request.POST.get("umount_iso", "")
instance.proxy.umount_iso(dev, image)
msg = _("Mount media: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return redirect(request.META.get("HTTP_REFERER") + "#disks")
@require_POST
@superuser_only
@serialize_instance_mutation
def mount_iso(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
allow_admin_or_not_template = (
request.user.is_superuser or request.user.is_staff or not instance.is_template
)
if allow_admin_or_not_template:
image = request.POST.get("media", "")
dev = request.POST.get("mount_iso", "")
instance.proxy.mount_iso(dev, image)
msg = _("Unmount media: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
image = request.POST.get("media", "")
dev = request.POST.get("mount_iso", "")
instance.proxy.mount_iso(dev, image)
msg = _("Unmount media: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return redirect(request.META.get("HTTP_REFERER") + "#disks")

View file

@ -760,6 +760,15 @@ class wvmInstance(wvmConnect):
discard_mode=None,
detect_zeroes_mode=None,
):
# Every value below is interpolated into disk XML; escape them all so
# a quote in a path, serial or option cannot inject XML.
esc = util.xml_escape
target_dev, source, target_bus = esc(target_dev), esc(source), esc(target_bus)
disk_type, disk_device = esc(disk_type), esc(disk_device)
driver_name, format_type = esc(driver_name), esc(format_type)
serial = esc(serial)
cache_mode, io_mode = esc(cache_mode), esc(io_mode)
discard_mode, detect_zeroes_mode = esc(discard_mode), esc(detect_zeroes_mode)
additionals = ""
if (
@ -822,7 +831,7 @@ class wvmInstance(wvmConnect):
def detach_disk(self, target_dev):
tree = etree.fromstring(self._XMLDesc(0))
disk_el = tree.xpath("./devices/disk/target[@dev='{}']".format(target_dev))[
disk_el = tree.xpath("./devices/disk/target[@dev=$dev]", dev=target_dev)[
0
].getparent()
xml_disk = etree.tostring(disk_el).decode()
@ -850,7 +859,7 @@ class wvmInstance(wvmConnect):
detect_zeroes_mode,
):
tree = etree.fromstring(self._XMLDesc(0))
disk_el = tree.xpath("./devices/disk/target[@dev='{}']".format(target_dev))[
disk_el = tree.xpath("./devices/disk/target[@dev=$dev]", dev=target_dev)[
0
].getparent()
old_disk_type = disk_el.get("type")
@ -858,6 +867,15 @@ class wvmInstance(wvmConnect):
old_driver_name = disk_el.xpath("driver/@name")[0]
old_target_bus = disk_el.xpath("target/@bus")[0]
# Values below are interpolated into disk XML; escape them all.
esc = util.xml_escape
target_dev, source, target_bus = esc(target_dev), esc(source), esc(target_bus)
serial, format = esc(serial), esc(format)
cache_mode, io_mode = esc(cache_mode), esc(io_mode)
discard_mode, detect_zeroes_mode = esc(discard_mode), esc(detect_zeroes_mode)
old_disk_type, old_disk_device = esc(old_disk_type), esc(old_disk_device)
old_driver_name = esc(old_driver_name)
additionals = ""
if cache_mode is not None and cache_mode != "default":
additionals += f"cache='{cache_mode}' "

View file

@ -178,3 +178,67 @@ class TestXmlSafety(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class TestDiskXmlEscaping(unittest.TestCase):
def _instance(self):
inst = wvmInstance.__new__(wvmInstance)
inst.get_status = MagicMock(return_value=5) # shutoff
inst.instance = MagicMock()
return inst
def test_attach_disk_escapes_source_and_serial(self):
inst = self._instance()
inst.attach_disk(
"vdb",
"/images/a'/><disk type='file",
target_bus="virtio",
disk_type="file",
serial="S1</serial><evil/>",
)
xml = inst.instance.attachDeviceFlags.call_args[0][0]
self.assertIn("file='/images/a&apos;/&gt;&lt;disk type=&apos;file'", xml)
self.assertIn("<serial>S1&lt;/serial&gt;&lt;evil/&gt;</serial>", xml)
self.assertNotIn("<evil/>", xml)
def test_edit_disk_escapes_source_and_serial(self):
inst = self._instance()
inst._XMLDesc = MagicMock(
return_value=(
"<domain><devices><disk type='file' device='disk'>"
"<driver name='qemu' type='qcow2'/>"
"<source file='/images/vm.qcow2'/>"
"<target dev='vda' bus='virtio'/>"
"</disk></devices></domain>"
)
)
inst.edit_disk(
"vda",
"/images/b'/><x y='",
False,
False,
"virtio",
"S2<evil/>",
"qcow2",
"default",
"default",
"default",
"default",
)
xml = inst.instance.updateDeviceFlags.call_args[0][0]
self.assertIn("file='/images/b&apos;/&gt;&lt;x y=&apos;'", xml)
self.assertNotIn("<evil/>", xml)
def test_detach_disk_does_not_evaluate_dev_as_xpath(self):
inst = self._instance()
inst._XMLDesc = MagicMock(
return_value=(
"<domain><devices><disk type='file' device='disk'>"
"<source file='/images/vm.qcow2'/>"
"<target dev='vda' bus='virtio'/>"
"</disk></devices></domain>"
)
)
with self.assertRaises(IndexError):
inst.detach_disk("x' or '1'='1")
inst.instance.detachDeviceFlags.assert_not_called()