Skip to content

Commit 422604d

Browse files
committed
[fix] Prevent command creation for devices without a DeviceConnection #1016
Verify that a device has associated DeviceConnection credentials before allowing to create new Command objects. Additionally, enforce validation of connection and command types to ensure only permitted values are diasplayed and accepted through the REST API. Closes #1016 Signed-off-by: DragnEmperor <dragnemperor@gmail.com> (cherry picked from commit 4836913) # Conflicts: # openwisp_controller/connection/base/models.py
1 parent 73f383b commit 422604d

4 files changed

Lines changed: 94 additions & 11 deletions

File tree

openwisp_controller/connection/api/serializer.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ class CommandSerializer(ValidatedDeviceFieldSerializer):
3131
pk_field=serializers.UUIDField(format='hex_verbose'),
3232
)
3333

34+
def __init__(self, *args, **kwargs):
35+
super().__init__(*args, **kwargs)
36+
# show only connections and command types available for the device
37+
if device_id := self.context.get('device_id'):
38+
self.fields['connection'].queryset = self.fields[
39+
'connection'
40+
].queryset.filter(device_id=device_id)
41+
device = Device.objects.only('organization_id', 'id').get(pk=device_id)
42+
# filter command types based on the device's organization
43+
self.fields['type'].choices = Command.get_org_allowed_commands(
44+
device.organization_id
45+
)
46+
3447
def to_representation(self, instance):
3548
repr = super().to_representation(instance)
3649
repr['type'] = instance.get_type_display()

openwisp_controller/connection/base/models.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -424,10 +424,17 @@ class Meta:
424424
ordering = ('created',)
425425

426426
@classmethod
427-
def get_org_choices(self, organization_id=None):
428-
return ORGANIZATION_ENABLED_COMMANDS.get(
427+
def get_org_allowed_commands(self, organization_id=None):
428+
"""
429+
Returns a list of allowed commands for the given organization
430+
"""
431+
allowed_commands = ORGANIZATION_ENABLED_COMMANDS.get(
429432
str(organization_id), ORGANIZATION_ENABLED_COMMANDS.get('__all__')
430433
)
434+
commands_map = dict(COMMAND_CHOICES)
435+
return [
436+
(cmd, commands_map[cmd]) for cmd in allowed_commands if cmd in commands_map
437+
]
431438

432439
@classmethod
433440
def get_org_schema(self, organization_id=None):
@@ -445,8 +452,27 @@ def __str__(self):
445452
return f'«{command}» {sent} {created.strftime("%d %b %Y at %I:%M %p")}'
446453

447454
def clean(self):
448-
if self.type not in self.get_org_choices(
449-
organization_id=self.device.organization_id
455+
self._verify_command_type_allowed()
456+
self._verify_connection()
457+
try:
458+
jsonschema.Draft4Validator(self._schema).validate(self.input)
459+
except SchemaError as e:
460+
raise ValidationError({'input': e.message})
461+
462+
def _verify_connection(self):
463+
"""Raises validation error if device has no connection and credentials."""
464+
if self.device and not self.device.deviceconnection_set.exists():
465+
raise ValidationError({'device': _('Device has no credentials assigned.')})
466+
467+
def _verify_command_type_allowed(self):
468+
"""Raises validation error if command type is not allowed."""
469+
# if device is not set, skip to avoid uncaught exception
470+
# (standard model validation will kick in)
471+
if not hasattr(self, 'device'):
472+
return
473+
474+
if self.type not in dict(
475+
self.get_org_allowed_commands(organization_id=self.device.organization_id)
450476
):
451477
raise ValidationError(
452478
{
@@ -458,10 +484,6 @@ def clean(self):
458484
)
459485
}
460486
)
461-
try:
462-
jsonschema.Draft4Validator(self._schema).validate(self.input)
463-
except SchemaError as e:
464-
raise ValidationError({'input': e.message})
465487

466488
@property
467489
def is_custom(self):

openwisp_controller/connection/tests/test_api.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,22 @@ def test_command_attributes(self, payload):
175175
self.assertEqual(response.status_code, 201)
176176
test_command_attributes(self, payload)
177177

178+
# for ensuring that only related connections are shown
179+
def test_available_connections(self):
180+
device = self._create_device(
181+
name='default.test.device2', mac_address='12:23:34:45:56:67'
182+
)
183+
self._create_config(device=device)
184+
credentials_2 = self._create_credentials(name='Test Credentials 2')
185+
device_conn2 = self._create_device_connection(
186+
device=device, credentials=credentials_2
187+
)
188+
url = self._get_path('device_command_list', self.device_id)
189+
response = self.client.get(url, {'format': 'api'})
190+
self.assertEqual(response.status_code, 200)
191+
self.assertContains(response, str(self.device_conn.id))
192+
self.assertNotContains(response, device_conn2.id)
193+
178194
def test_command_details_api(self):
179195
command_obj = self._create_command(device_conn=self.device_conn)
180196
url = self._get_path('device_command_details', self.device_id, command_obj.id)
@@ -338,10 +354,30 @@ def test_non_existent_command(self):
338354
)
339355
self.assertEqual(response.status_code, 400)
340356
self.assertIn(
341-
'"custom" command is not available for this organization',
342-
response.data['input'][0],
357+
'"custom" is not a valid choice.',
358+
response.data['type'][0],
343359
)
344360

361+
def test_create_command_without_connection(self):
362+
device = self._create_device(
363+
name='default.test.device2', mac_address='11:22:33:44:55:66'
364+
)
365+
url = self._get_path('device_command_list', device.pk)
366+
payload = {
367+
'type': 'custom',
368+
'input': {'command': 'echo test'},
369+
}
370+
response = self.client.post(
371+
url,
372+
data=json.dumps(payload),
373+
content_type='application/json',
374+
)
375+
self.assertEqual(response.status_code, 400)
376+
self.assertIn(
377+
'Device has no credentials assigned.',
378+
response.data['device'][0],
379+
)
380+
345381

346382
class TestConnectionApi(
347383
TestAdminMixin, AuthenticationMixin, TestCase, CreateConnectionsMixin

openwisp_controller/connection/tests/test_models.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,18 @@ def test_command_validation(self):
564564
],
565565
)
566566

567+
with self.subTest('Test command creation without device connection'):
568+
device = dc.device
569+
device.deviceconnection_set.all().delete()
570+
with self.assertRaises(ValidationError) as context_manager:
571+
command.full_clean()
572+
exception = context_manager.exception
573+
self.assertIn('device', exception.message_dict)
574+
self.assertEqual(
575+
exception.message_dict['device'],
576+
['Device has no credentials assigned.'],
577+
)
578+
567579
@tag('skip_prod')
568580
def test_enabled_command(self):
569581
self.assertEqual(
@@ -787,7 +799,7 @@ def _command_assertions(destination_address, mocked_exec_command):
787799

788800
@mock.patch(_connect_path)
789801
@mock.patch.dict(COMMANDS, {})
790-
@mock.patch.dict(ORGANIZATION_ENABLED_COMMANDS, {'__all__': ('restart_network')})
802+
@mock.patch.dict(ORGANIZATION_ENABLED_COMMANDS, {'__all__': ('restart_network',)})
791803
@mock.patch(_exec_command_path)
792804
def test_execute_user_registered_command_without_input(
793805
self, mocked_exec_command, connect_mocked

0 commit comments

Comments
 (0)