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:
parent
3f8eeb04bd
commit
b3e6fb2b7a
9 changed files with 383 additions and 63 deletions
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
64
accounts/test_security_fixes.py
Normal file
64
accounts/test_security_fixes.py
Normal 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"))
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
107
instances/test_security_mutations.py
Normal file
107
instances/test_security_mutations.py
Normal 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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue