1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-10 04:06: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

@ -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")