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

feat(security): enforce require_POST on power actions, prevent open redirects, and isolate instance permissions

This commit is contained in:
catborise 2026-09-27 13:20:45 +03:00
parent 3f8eeb04bd
commit b3e6fb2b7a
9 changed files with 383 additions and 63 deletions

View file

@ -0,0 +1,41 @@
# Generated by Django 4.2.30 on 2026-09-27 10:18
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('accounts', '0005_auto_20200616_1039'),
]
operations = [
migrations.AlterField(
model_name='userattributes',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='userinstance',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='usersshkey',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='usersshkey',
name='keypublic',
field=models.TextField(max_length=16384, verbose_name='public key'),
),
migrations.AlterField(
model_name='usersshkey',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View file

@ -30,9 +30,9 @@ class UserInstance(models.Model):
class UserSSHKey(models.Model):
user = models.ForeignKey(User, on_delete=models.DO_NOTHING)
user = models.ForeignKey(User, on_delete=models.CASCADE)
keyname = models.CharField(_("key name"), max_length=25)
keypublic = models.CharField(_("public key"), max_length=500)
keypublic = models.TextField(_("public key"), max_length=16384)
def __str__(self):
return self.keyname

View file

@ -0,0 +1,64 @@
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 accounts.utils import validate_ssh_key
from computes.models import Compute
from instances.models import Instance
class AccountsSecurityFixesTestCase(TestCase):
def setUp(self):
User = get_user_model()
self.admin = User.objects.create_superuser(
username="sec_acc_admin", email="admin@example.com", password="password"
)
self.user = User.objects.create_user(
username="sec_acc_user", password="password"
)
self.compute = Compute.objects.create(
name="acc-comp", hostname="127.0.0.1", login="root", type=1
)
self.instance = Instance.objects.create(
compute=self.compute, name="acc-vm", uuid="99999999-9999-9999-9999-999999999999"
)
self.user_inst = UserInstance.objects.create(
instance=self.instance, user=self.user
)
def test_open_redirect_prevented_in_user_instance_delete(self):
self.client.force_login(self.admin)
url = reverse("accounts:user_instance_delete", args=[self.user_inst.id])
# Attempt external malicious redirect
res = self.client.post(f"{url}?next=https://malicious-attacker.com")
self.assertEqual(res.status_code, 302)
# Should redirect to safe internal URL (user account page), NOT external site
self.assertNotIn("malicious-attacker.com", res.url)
self.assertEqual(res.url, reverse("accounts:account", args=[self.user.id]))
def test_ssh_key_cascade_deletion(self):
key = UserSSHKey.objects.create(
user=self.user,
keyname="my-key",
keypublic="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGabcdef123456 test@example.com",
)
self.assertTrue(UserSSHKey.objects.filter(id=key.id).exists())
# When user is deleted, key should be deleted via CASCADE (not left orphan)
self.user.delete()
self.assertFalse(UserSSHKey.objects.filter(id=key.id).exists())
def test_validate_ssh_key_supports_keys_with_and_without_comment(self):
rsa_base = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC6OOdbfv27QVnSC6sKxGaHb6YFc+3gxCkyVR3cTSXE/n5BEGf8aOgBpepULWa1RZfxYHY14PlKULDygdXSdrrR2kNSwoKz/Oo4d+3EE92L7ocl1+djZbptzgWgtw1OseLwbFik+iKlIdqPsH+IUQvX7yV545ZQtAP8Qj1R+uCqkw=="
# Key with comment
key_with_comment = f"{rsa_base} test@test"
self.assertTrue(validate_ssh_key(key_with_comment))
# Key without comment
key_without_comment = rsa_base
self.assertTrue(validate_ssh_key(key_without_comment))
# Invalid key
self.assertFalse(validate_ssh_key("not a valid key string"))

View file

@ -20,10 +20,9 @@ def get_user_totp_device(user):
def validate_ssh_key(key):
array = key.encode().split()
# Each rsa-ssh key has 3 different strings in it, first one being
# typeofkey second one being keystring third one being username .
if len(array) != 3:
array = key.strip().encode().split()
# An OpenSSH public key has 2 parts (type, key) or 3 parts (type, key, comment).
if len(array) not in (2, 3):
return False
typeofkey = array[0]
string = array[1]

View file

@ -7,6 +7,7 @@ from django.contrib.auth.forms import PasswordChangeForm
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.http import HttpResponseRedirect
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy as _
from instances.models import Instance
@ -161,11 +162,14 @@ def user_instance_delete(request, pk):
if request.method == "POST":
user = user_instance.user
user_instance.delete()
next = request.GET.get("next", None)
if next:
return redirect(next)
else:
return redirect(reverse("accounts:account", args=[user.id]))
next_url = request.GET.get("next", None)
if next_url and url_has_allowed_host_and_scheme(
url=next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return redirect(next_url)
return redirect(reverse("accounts:account", args=[user.id]))
return render(
request,

View file

@ -2,6 +2,7 @@ from admin.permissions import IsSuperUser
from appsettings.settings import app_settings
from computes import utils
from computes.models import Compute
from django.http import Http404
from django.shortcuts import get_object_or_404
from instances.models import Flavor, Instance
from instances.utils import migrate_instance
@ -99,41 +100,64 @@ class InstanceViewSet(viewsets.ViewSet):
def retrieve(self, request, pk=None, compute_pk=None):
queryset = get_instance(request.user, pk)
if compute_pk is not None and str(queryset.compute_id) != str(compute_pk):
raise Http404()
serializer = InstanceDetailsSerializer(queryset, context={"request": request})
return Response(serializer.data)
def destroy(self, request, pk=None, compute_pk=None):
queryset = get_instance(request.user, pk, perm_type="delete")
if compute_pk is not None and str(queryset.compute_id) != str(compute_pk):
raise Http404()
instance_destroy(request, pk)
return Response({"status": "Instance is destroyed"})
@action(detail=True, methods=["post"])
def poweron(self, request, pk=None):
def poweron(self, request, pk=None, compute_pk=None):
queryset = get_instance(request.user, pk, perm_type="power")
if compute_pk is not None and str(queryset.compute_id) != str(compute_pk):
raise Http404()
poweron(request, pk)
return Response({"status": "poweron command send"})
@action(detail=True, methods=["post"])
def poweroff(self, request, pk=None):
def poweroff(self, request, pk=None, compute_pk=None):
queryset = get_instance(request.user, pk, perm_type="power")
if compute_pk is not None and str(queryset.compute_id) != str(compute_pk):
raise Http404()
poweroff(request, pk)
return Response({"status": "poweroff command send"})
@action(detail=True, methods=["post"])
def powercycle(self, request, pk=None):
def powercycle(self, request, pk=None, compute_pk=None):
queryset = get_instance(request.user, pk, perm_type="power")
if compute_pk is not None and str(queryset.compute_id) != str(compute_pk):
raise Http404()
powercycle(request, pk)
return Response({"status": "powercycle command send"})
@action(detail=True, methods=["post"])
def forceoff(self, request, pk=None):
def forceoff(self, request, pk=None, compute_pk=None):
queryset = get_instance(request.user, pk, perm_type="power")
if compute_pk is not None and str(queryset.compute_id) != str(compute_pk):
raise Http404()
force_off(request, pk)
return Response({"status": "force off command send"})
@action(detail=True, methods=["post"])
def suspend(self, request, pk=None):
def suspend(self, request, pk=None, compute_pk=None):
queryset = get_instance(request.user, pk)
if compute_pk is not None and str(queryset.compute_id) != str(compute_pk):
raise Http404()
suspend(request, pk)
return Response({"status": "suspend command send"})
@action(detail=True, methods=["post"])
def resume(self, request, pk=None):
def resume(self, request, pk=None, compute_pk=None):
queryset = get_instance(request.user, pk)
if compute_pk is not None and str(queryset.compute_id) != str(compute_pk):
raise Http404()
resume(request, pk)
return Response({"status": "resume command send"})

View file

@ -1,48 +1,71 @@
{% load i18n %}
{% load bootstrap_icons %}
<form action="" method="post" role="form" aria-label="Shortcut instance action form">{% csrf_token %}
<div class="btn-group" role="group" aria-label="Instance actions">
{% if instance.proxy.instance.info.0 == 5 %}
{% if instance.is_template %}
<a href="{% url 'instances:instance' instance.id %}#clone" class="btn btn-sm btn-secondary" title="{% trans "Clone" %}">
{% bs_icon 'copy' %}
</a>
{% else %}
<a class="btn btn-sm btn-secondary" href="{% url 'instances:poweron' instance.id %}" onclick="showPleaseWaitDialog()" title="{% trans "Power On" %}">
{% bs_icon 'play-fill' %}
</a>
<form action="{% url 'instances:poweron' instance.id %}" method="post" style="display:inline;" onsubmit="showPleaseWaitDialog()">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-secondary" title="{% trans "Power On" %}">
{% bs_icon 'play-fill' %}
</button>
</form>
{% endif %}
<a class="btn btn-sm btn-secondary disabled" title="{% trans "Suspend" %}">{% bs_icon 'pause-fill' %}</a>
<a class="btn btn-sm btn-secondary disabled" title="{% trans "Power Off" %}">{% bs_icon 'power' %}</a>
<a class="btn btn-sm btn-secondary disabled" title="{% trans "Power Cycle" %}">{% bs_icon 'repeat' %}</a>
<button class="btn btn-sm btn-secondary disabled" title="{% trans "Suspend" %}" disabled>{% bs_icon 'pause-fill' %}</button>
<button class="btn btn-sm btn-secondary disabled" title="{% trans "Power Off" %}" disabled>{% bs_icon 'power' %}</button>
<button class="btn btn-sm btn-secondary disabled" title="{% trans "Power Cycle" %}" disabled>{% bs_icon 'repeat' %}</button>
<button class="btn btn-sm btn-secondary disabled" title="{% trans "VNC Console" %}" disabled>
<span>{% bs_icon 'eye-fill' %}</span>
</button>
{% endif %}
{% if instance.proxy.instance.info.0 == 3 %}
<a class="btn btn-sm btn-secondary" href="{% url 'instances:resume' instance.id %}" title="{% trans "Resume" %}">
{% bs_icon 'play-fill' %}
</a>
<a class="btn btn-sm btn-secondary disabled" title="{% trans "Suspend" %}">{% bs_icon 'pause-fill' %}</a>
<a class="btn btn-sm btn-secondary" href="{% url 'instances:force_off' instance.id %}" title="{% trans "Force Off" %}">
{% bs_icon 'power' %}
</a>
<a class="btn btn-sm btn-secondary disabled" title="{% trans "Power Cycle" %}">{% bs_icon 'repeat' %}</a>
<form action="{% url 'instances:resume' instance.id %}" method="post" style="display:inline;">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-secondary" title="{% trans "Resume" %}">
{% bs_icon 'play-fill' %}
</button>
</form>
<button class="btn btn-sm btn-secondary disabled" title="{% trans "Suspend" %}" disabled>{% bs_icon 'pause-fill' %}</button>
<form action="{% url 'instances:force_off' instance.id %}" method="post" style="display:inline;">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-secondary" title="{% trans "Force Off" %}">
{% bs_icon 'power' %}
</button>
</form>
<button class="btn btn-sm btn-secondary disabled" title="{% trans "Power Cycle" %}" disabled>{% bs_icon 'repeat' %}</button>
<button class="btn btn-sm btn-secondary disabled" title="{% trans "VNC Console" %}" disabled>
<span>{% bs_icon 'eye-fill' %}</span>
</button>
{% endif %}
{% if instance.proxy.instance.info.0 == 1 %}
<a class="btn btn-sm btn-secondary disabled" onclick="showPleaseWaitDialog()" title="{% trans "Power On" %}">{% bs_icon 'play-fill' %}</a>
<a class="btn btn-sm btn-secondary" href="{% url 'instances:suspend' instance.id %}"
title="{% trans "Suspend" %}">{% bs_icon 'pause-fill' %}</a>
<a class="btn btn-sm btn-secondary" href="{% url 'instances:poweroff' instance.id %}">{% bs_icon 'power' %}</a>
<a class="btn btn-sm btn-secondary" href="{% url 'instances:powercycle' instance.id %}">{% bs_icon 'repeat' %}</a>
<button class="btn btn-sm btn-secondary disabled" title="{% trans "Power On" %}" disabled>{% bs_icon 'play-fill' %}</button>
<form action="{% url 'instances:suspend' instance.id %}" method="post" style="display:inline;">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-secondary" title="{% trans "Suspend" %}">
{% bs_icon 'pause-fill' %}
</button>
</form>
<form action="{% url 'instances:poweroff' instance.id %}" method="post" style="display:inline;">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-secondary" title="{% trans "Power Off" %}">
{% bs_icon 'power' %}
</button>
</form>
<form action="{% url 'instances:powercycle' instance.id %}" method="post" style="display:inline;">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-secondary" title="{% trans "Power Cycle" %}">
{% bs_icon 'repeat' %}
</button>
</form>
<button class="btn btn-sm btn-secondary" type="button" onclick='open_console("{{ instance.compute.id }}-{{ instance.get_uuid }}")'
title="{% trans "Console" %}">
<span>{% bs_icon 'eye-fill' %}</span>
</button>
{% endif %}
</form>
</div>
{% block script %}
<script>
function open_console(uuid) {

View file

@ -0,0 +1,107 @@
from unittest.mock import MagicMock, patch
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
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
class InstanceSecurityMutationsTestCase(TestCase):
def setUp(self):
User = get_user_model()
self.superuser = User.objects.create_superuser(
username="sec_super", email="super@example.com", password="password"
)
self.owner = User.objects.create_user(
username="sec_owner", password="password"
)
self.change_user = User.objects.create_user(
username="sec_change_user", password="password"
)
self.viewer = User.objects.create_user(
username="sec_viewer", password="password"
)
# Viewer only has global view_instances permission
view_perm = Permission.objects.get(codename="view_instances")
self.viewer.user_permissions.add(view_perm)
self.compute = Compute.objects.create(
name="sec-compute",
hostname="127.0.0.1",
login="root",
password="",
type=1,
)
self.instance = Instance.objects.create(
compute=self.compute,
name="sec-test-vm",
uuid="11111111-2222-3333-4444-555555555555",
)
# Owner has basic userinstance (power enabled, change/delete disabled)
self.owner_ui = UserInstance.objects.create(
instance=self.instance, user=self.owner, is_change=False, is_delete=False
)
# Change user has is_change=True
self.change_ui = UserInstance.objects.create(
instance=self.instance, user=self.change_user, is_change=True, is_delete=False
)
# Mock instance.proxy on instance dict
self.mock_proxy = MagicMock()
self.instance.__dict__["proxy"] = self.mock_proxy
def test_power_endpoints_reject_get_requests(self):
self.client.force_login(self.owner)
endpoints = [
reverse("instances:poweron", args=[self.instance.id]),
reverse("instances:poweroff", args=[self.instance.id]),
reverse("instances:powercycle", args=[self.instance.id]),
reverse("instances:force_off", args=[self.instance.id]),
reverse("instances:suspend", args=[self.instance.id]),
reverse("instances:resume", args=[self.instance.id]),
]
for url in endpoints:
res = self.client.get(url)
self.assertEqual(res.status_code, 405, f"GET {url} should return 405")
def test_viewer_without_ownership_cannot_power_mutate(self):
self.client.force_login(self.viewer)
endpoints = [
reverse("instances:poweron", args=[self.instance.id]),
reverse("instances:poweroff", args=[self.instance.id]),
reverse("instances:powercycle", args=[self.instance.id]),
reverse("instances:force_off", args=[self.instance.id]),
]
for url in endpoints:
res = self.client.post(url)
self.assertEqual(res.status_code, 403, f"POST {url} should be 403 for read-only viewer")
def test_owner_can_power_mutate(self):
self.client.force_login(self.owner)
with patch.object(Instance, "proxy", new_callable=lambda: self.mock_proxy):
res = self.client.post(reverse("instances:poweron", args=[self.instance.id]))
self.assertEqual(res.status_code, 302)
def test_set_root_pass_requires_is_change(self):
# Owner without is_change -> 403
self.client.force_login(self.owner)
res = self.client.post(reverse("instances:rootpasswd", args=[self.instance.id]), {"passwd": "newpass"})
self.assertEqual(res.status_code, 403)
# Viewer without ownership -> 403
self.client.force_login(self.viewer)
res_v = self.client.post(reverse("instances:rootpasswd", args=[self.instance.id]), {"passwd": "newpass"})
self.assertEqual(res_v.status_code, 403)
def test_drf_compute_pk_mismatch_returns_404(self):
other_compute = Compute.objects.create(
name="other-compute", hostname="127.0.0.2", login="root", type=1
)
self.client.force_login(self.owner)
# Query instance via wrong compute_pk in nested router
url = f"/api/v1/computes/{other_compute.id}/instances/{self.instance.id}/"
res = self.client.get(url)
self.assertEqual(res.status_code, 404)

View file

@ -19,10 +19,13 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_noop as _
from django.views.decorators.http import require_POST
from libvirt import (VIR_DOMAIN_UNDEFINE_KEEP_NVRAM,
VIR_DOMAIN_UNDEFINE_NVRAM,
VIR_DOMAIN_START_PAUSED,
@ -316,25 +319,59 @@ def sshkeys(request, pk):
return HttpResponse(response)
def get_instance(user, pk):
def get_safe_redirect(request, default=None):
referer = request.META.get("HTTP_REFERER")
if referer and url_has_allowed_host_and_scheme(
url=referer,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return redirect(referer)
if default:
return redirect(default)
return redirect("instances:index")
def get_instance(user, pk, perm_type="view"):
"""
Check that instance is available for user, if not raise 404
Check that instance is available for user, if not raise 404 or PermissionDenied.
perm_type:
- 'view': superuser, has_perm("instances.view_instances"), or UserInstance owner
- 'power': superuser or UserInstance owner
- 'change': superuser or (UserInstance owner and is_change)
- 'delete': superuser or (UserInstance owner and is_delete)
"""
instance = get_object_or_404(Instance, pk=pk)
user_instances = user.userinstance_set.all().values_list("instance", flat=True)
user_instances = user.userinstance_set.filter(instance=instance)
has_owner_rel = user_instances.exists()
if (
if not (
user.is_superuser
or user.has_perm("instances.view_instances")
or instance.id in user_instances
or has_owner_rel
):
return instance
else:
raise Http404()
if user.is_superuser or perm_type == "view":
return instance
user_inst = user_instances.first()
if perm_type == "power":
if not has_owner_rel:
raise PermissionDenied
elif perm_type == "change":
if not (user_inst and user_inst.is_change):
raise PermissionDenied
elif perm_type == "delete":
if not (user_inst and user_inst.is_delete):
raise PermissionDenied
return instance
@require_POST
def poweron(request, pk):
instance = get_instance(request.user, pk)
instance = get_instance(request.user, pk, perm_type="power")
if instance.is_template:
messages.warning(request, _("Templates cannot be started."))
else:
@ -343,52 +380,69 @@ def poweron(request, pk):
request.user.username, instance.compute.name, instance.name, _("Power On")
)
return redirect(request.META.get("HTTP_REFERER"))
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
@require_POST
def powercycle(request, pk):
instance = get_instance(request.user, pk)
instance = get_instance(request.user, pk, perm_type="power")
instance.proxy.force_shutdown()
instance.proxy.start()
addlogmsg(
request.user.username, instance.compute.name, instance.name, _("Power Cycle")
)
return redirect(request.META.get("HTTP_REFERER"))
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
@require_POST
def poweroff(request, pk):
instance = get_instance(request.user, pk)
instance = get_instance(request.user, pk, perm_type="power")
instance.proxy.shutdown()
addlogmsg(
request.user.username, instance.compute.name, instance.name, _("Power Off")
)
return redirect(request.META.get("HTTP_REFERER"))
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
@require_POST
@superuser_only
def suspend(request, pk):
instance = get_instance(request.user, pk)
instance.proxy.suspend()
addlogmsg(request.user.username, instance.compute.name, instance.name, _("Suspend"))
return redirect(request.META.get("HTTP_REFERER"))
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
@require_POST
@superuser_only
def resume(request, pk):
instance = get_instance(request.user, pk)
instance.proxy.resume()
addlogmsg(request.user.username, instance.compute.name, instance.name, _("Resume"))
return redirect(request.META.get("HTTP_REFERER"))
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
@require_POST
def force_off(request, pk):
instance = get_instance(request.user, pk)
instance = get_instance(request.user, pk, perm_type="power")
instance.proxy.force_shutdown()
addlogmsg(
request.user.username, instance.compute.name, instance.name, _("Force Off")
)
return redirect(request.META.get("HTTP_REFERER"))
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
def destroy(request, pk):
@ -398,7 +452,9 @@ def destroy(request, pk):
except Exception:
userinstance = UserInstance(is_delete=request.user.is_superuser)
if request.method in ["POST", "DELETE"] and userinstance.is_delete:
if request.method in ["POST", "DELETE"]:
if not userinstance.is_delete:
raise PermissionDenied
if instance.proxy.get_status() == 1:
instance.proxy.force_shutdown()
@ -470,11 +526,13 @@ def migrate(request, pk):
}
addlogmsg(request.user.username, current_host, instance.name, msg)
return redirect(request.META.get("HTTP_REFERER"))
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
def set_root_pass(request, pk):
instance = get_instance(request.user, pk)
instance = get_instance(request.user, pk, perm_type="change")
if request.method == "POST":
passwd = request.POST.get("passwd", None)
@ -504,10 +562,10 @@ def set_root_pass(request, pk):
def add_public_key(request, pk):
instance = get_instance(request.user, pk)
instance = get_instance(request.user, pk, perm_type="change")
if request.method == "POST":
sshkeyid = request.POST.get("sshkeyid", "")
publickey = UserSSHKey.objects.get(id=sshkeyid)
publickey = get_object_or_404(UserSSHKey, id=sshkeyid, user=request.user)
data = {
"action": "publickey",
"key": publickey.keypublic,
@ -1918,7 +1976,7 @@ def flavor_create(request):
if form.is_valid():
form.save()
messages.success(request, _("Flavor Created"))
return redirect(request.META.get("HTTP_REFERER"))
return get_safe_redirect(request, default=reverse("instances:flavors"))
return render(
request,
@ -1934,7 +1992,7 @@ def flavor_update(request, pk):
if form.is_valid():
form.save()
messages.success(request, _("Flavor Updated"))
return redirect(request.META.get("HTTP_REFERER"))
return get_safe_redirect(request, default=reverse("instances:flavors"))
return render(
request,
@ -1949,7 +2007,7 @@ def flavor_delete(request, pk):
if request.method == "POST":
flavor.delete()
messages.success(request, _("Flavor Deleted"))
return redirect(request.META.get("HTTP_REFERER"))
return get_safe_redirect(request, default=reverse("instances:flavors"))
return render(
request,