Skip to content

Commit bdf7654

Browse files
committed
[firmware-upgrader] Prevent upgrade operations on deactivated devices #382
Added comprehensive validation to prevent firmware upgrade operations on deactivated devices across all application layers: - Added model validation in AbstractDeviceFirmware.clean() and AbstractUpgradeOperation.clean() to reject operations on deactivated devices - Enhanced API validation in DeviceFirmwareSerializer to validate device state - Updated batch operation filtering in _find_related_device_firmwares() and _find_firmwareless_devices() to exclude deactivated devices - Added comprehensive tests for model, API, and admin validation This ensures defense-in-depth protection where deactivated devices cannot be targeted for firmware upgrades through any interface (UI, API, or batch operations), preventing potential security issues. Fixes #382
1 parent a252676 commit bdf7654

6 files changed

Lines changed: 145 additions & 6 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ __pycache__/
88
# Distribution / packaging
99
.Python
1010
env/
11+
.venv/
1112
build/
1213
develop-eggs/
1314
dist/

openwisp_firmware_upgrader/api/serializers.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,12 @@ def validate(self, data):
150150
device_id = self.context.get("device_id")
151151
device = self._get_device_object(device_id)
152152
data.update({"device": device})
153-
image = data.get("image")
154153
device = data.get("device")
154+
if device and device.is_deactivated():
155+
raise ValidationError(
156+
_("Cannot create firmware object for deactivated device")
157+
)
158+
image = data.get("image")
155159
if (
156160
image
157161
and device

openwisp_firmware_upgrader/base/models.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ def _find_related_device_firmwares(
206206
.select_related(*related)
207207
.filter(image__build__category_id=self.category_id)
208208
.exclude(image__build=self, installed=True)
209+
.exclude(device___is_deactivated=True)
209210
.order_by("-created")
210211
)
211212
if group:
@@ -227,7 +228,7 @@ def _find_firmwareless_devices(self, boards=None, group=None, location=None):
227228
qs = Device.objects.filter(
228229
devicefirmware__isnull=True,
229230
model__in=boards,
230-
)
231+
).exclude(_is_deactivated=True)
231232
if self.category.organization_id:
232233
qs = qs.filter(organization_id=self.category.organization_id)
233234
if group:
@@ -391,6 +392,10 @@ class Meta:
391392
def clean(self):
392393
if not hasattr(self, "image") or not hasattr(self, "device"):
393394
return
395+
if self.device.is_deactivated():
396+
raise ValidationError(
397+
_("Cannot create firmware object for deactivated device")
398+
)
394399
if (
395400
self.image.build.category.organization is not None
396401
and self.image.build.category.organization != self.device.organization
@@ -827,6 +832,13 @@ class AbstractUpgradeOperation(UpgradeOptionsMixin, TimeStampedEditableModel):
827832
class Meta:
828833
abstract = True
829834

835+
def clean(self):
836+
super().clean()
837+
if hasattr(self, "device") and self.device and self.device.is_deactivated():
838+
raise ValidationError(
839+
_("Cannot create upgrade operation for deactivated device")
840+
)
841+
830842
def log_line(self, line, save=True):
831843
if self.log:
832844
self.log += f"\n{line}"

openwisp_firmware_upgrader/tests/test_admin.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
88
from django.contrib.auth import get_user_model
99
from django.contrib.auth.models import Permission
10+
from django.core.exceptions import ValidationError
1011
from django.test import RequestFactory, TestCase, TransactionTestCase
1112
from django.urls import reverse
1213
from django.utils.timezone import localtime
@@ -392,6 +393,9 @@ def test_device_firmware_upgrade_without_device_connection(
392393
def test_deactivated_firmware_image_inline(self):
393394
self._login()
394395
device = self._create_config(organization=self._get_org()).device
396+
# Create device firmware BEFORE deactivating device
397+
self._create_device_firmware(device=device)
398+
# Now deactivate the device
395399
device.deactivate()
396400
response = self.client.get(
397401
reverse("admin:config_device_change", args=[device.id])
@@ -403,10 +407,7 @@ def test_deactivated_firmware_image_inline(self):
403407
'<input type="hidden" name="devicefirmware-MAX_NUM_FORMS"'
404408
' value="0" id="id_devicefirmware-MAX_NUM_FORMS">',
405409
)
406-
self._create_device_firmware(device=device)
407-
response = self.client.get(
408-
reverse("admin:config_device_change", args=[device.id])
409-
)
410+
410411
# Ensure that a deactivated device's existing DeviceFirmwareImage
411412
# is displayed as readonly in the admin interface.
412413
self.assertContains(
@@ -1464,5 +1465,32 @@ def test_batch_upgrade_operation_list_location_filter(self, *args):
14641465
self.assertNotContains(response, str(batch2.pk))
14651466
self.assertNotContains(response, str(batch3.pk))
14661467

1468+
def test_device_firmware_inline_deactivated_device(self, *args):
1469+
"""Test that DeviceFirmware inline shows validation error for deactivated device"""
1470+
device_fw = self._create_device_firmware()
1471+
device = device_fw.device
1472+
device.deactivate()
1473+
1474+
# Test that trying to create a new DeviceFirmware for deactivated device fails
1475+
with self.assertRaises(ValidationError) as cm:
1476+
new_device_fw = DeviceFirmware(device=device, image=device_fw.image)
1477+
new_device_fw.full_clean()
1478+
1479+
self.assertIn(
1480+
"Cannot create firmware object for deactivated device", str(cm.exception)
1481+
)
1482+
1483+
@mock.patch("openwisp_firmware_upgrader.tasks.upgrade_firmware.delay")
1484+
def test_basic_deactivated_device_admin_validation(self, *args):
1485+
"""Test basic admin validation for deactivated devices"""
1486+
device_fw = self._create_device_firmware()
1487+
device = device_fw.device
1488+
1489+
# Test that validation works at the model level
1490+
device.deactivate()
1491+
with self.assertRaises(ValidationError):
1492+
new_device_fw = DeviceFirmware(device=device, image=device_fw.image)
1493+
new_device_fw.full_clean()
1494+
14671495

14681496
del TestConfigAdmin

openwisp_firmware_upgrader/tests/test_api.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import swapper
55
from django.contrib.auth import get_user_model
6+
from django.core.exceptions import ValidationError
67
from django.test import Client, TestCase
78
from django.urls import reverse
89
from packaging.version import parse as parse_version
@@ -2115,3 +2116,22 @@ def get_download_url(self):
21152116
"upgrader:api_firmware_download",
21162117
args=[self.image.build.pk, self.image.pk],
21172118
)
2119+
2120+
2121+
class TestDeactivatedDeviceAPI(TestAPIUpgraderMixin, TestCase):
2122+
"""Test API behavior with deactivated devices"""
2123+
2124+
def test_device_firmware_basic_deactivated_validation(self):
2125+
"""Test that DeviceFirmware creation is rejected for deactivated devices"""
2126+
device_fw = self._create_device_firmware()
2127+
device = device_fw.device
2128+
device.deactivate()
2129+
2130+
# Test that we cannot create new device firmware for deactivated device
2131+
with self.assertRaises(ValidationError) as cm:
2132+
new_device_fw = DeviceFirmware(device=device, image=device_fw.image)
2133+
new_device_fw.full_clean()
2134+
2135+
self.assertIn(
2136+
"Cannot create firmware object for deactivated device", str(cm.exception)
2137+
)

openwisp_firmware_upgrader/tests/test_models.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,3 +992,77 @@ def test_batch_upgrade_with_group_and_location_filtering(self, *_args):
992992
self.assertEqual(len(upgrade_ops), 1)
993993
batch.refresh_from_db()
994994
self.assertEqual(batch.status, "success")
995+
996+
def test_device_firmware_deactivated_device_validation(self):
997+
"""Test that DeviceFirmware clean method raises ValidationError for deactivated device"""
998+
device_fw = self._create_device_firmware(upgrade=False)
999+
device_fw.device.deactivate()
1000+
1001+
with self.assertRaises(ValidationError) as ctx:
1002+
device_fw.clean()
1003+
1004+
self.assertIn(
1005+
"Cannot create firmware object for deactivated device", str(ctx.exception)
1006+
)
1007+
1008+
def test_upgrade_operation_deactivated_device_validation(self):
1009+
"""Test that UpgradeOperation clean method raises ValidationError for deactivated device"""
1010+
device_fw = self._create_device_firmware(upgrade=False)
1011+
device_fw.device.deactivate()
1012+
1013+
upgrade_op = UpgradeOperation(device=device_fw.device, image=device_fw.image)
1014+
1015+
with self.assertRaises(ValidationError) as ctx:
1016+
upgrade_op.clean()
1017+
1018+
self.assertIn(
1019+
"Cannot create upgrade operation for deactivated device", str(ctx.exception)
1020+
)
1021+
1022+
def test_device_firmware_save_deactivated_device(self):
1023+
"""Test that DeviceFirmware save fails for deactivated device"""
1024+
device_fw = self._create_device_firmware(upgrade=False)
1025+
device_fw.device.deactivate()
1026+
1027+
# Create new DeviceFirmware instance for deactivated device
1028+
new_device_fw = DeviceFirmware(device=device_fw.device, image=device_fw.image)
1029+
1030+
with self.assertRaises(ValidationError):
1031+
new_device_fw.full_clean()
1032+
1033+
@mock.patch("openwisp_firmware_upgrader.tasks.upgrade_firmware.delay")
1034+
def test_batch_upgrade_excludes_deactivated_devices(self, *args):
1035+
"""Test that batch upgrades exclude deactivated devices"""
1036+
env = self._create_upgrade_env()
1037+
# Deactivate one device
1038+
env["d1"].deactivate()
1039+
1040+
batch = env["build2"].batch_upgrade(firmwareless=False)
1041+
ops = UpgradeOperation.objects.filter(batch=batch)
1042+
1043+
# Should only have operations for non-deactivated devices
1044+
device_ids = [op.device.pk for op in ops]
1045+
self.assertNotIn(env["d1"].pk, device_ids) # deactivated device excluded
1046+
self.assertIn(env["d2"].pk, device_ids) # active device included
1047+
1048+
def test_deactivated_device_validation(self):
1049+
"""Test that model validation prevents operations on deactivated devices"""
1050+
device_fw = self._create_device_firmware()
1051+
device = device_fw.device
1052+
1053+
# Test DeviceFirmware validation
1054+
device.deactivate()
1055+
with self.assertRaises(ValidationError) as cm:
1056+
new_device_fw = DeviceFirmware(device=device, image=device_fw.image)
1057+
new_device_fw.full_clean()
1058+
self.assertIn(
1059+
"Cannot create firmware object for deactivated device", str(cm.exception)
1060+
)
1061+
1062+
# Test UpgradeOperation validation
1063+
with self.assertRaises(ValidationError) as cm:
1064+
operation = UpgradeOperation(device=device, image=device_fw.image)
1065+
operation.full_clean()
1066+
self.assertIn(
1067+
"Cannot create upgrade operation for deactivated device", str(cm.exception)
1068+
)

0 commit comments

Comments
 (0)