Skip to content

Commit 4836913

Browse files
authored
[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 <[email protected]>
1 parent f8ff255 commit 4836913

File tree

4 files changed

+82
-7
lines changed

4 files changed

+82
-7
lines changed

openwisp_controller/connection/api/serializers.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: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -437,10 +437,17 @@ class Meta:
437437
ordering = ('created',)
438438

439439
@classmethod
440-
def get_org_choices(self, organization_id=None):
441-
return ORGANIZATION_ENABLED_COMMANDS.get(
440+
def get_org_allowed_commands(self, organization_id=None):
441+
"""
442+
Returns a list of allowed commands for the given organization
443+
"""
444+
allowed_commands = ORGANIZATION_ENABLED_COMMANDS.get(
442445
str(organization_id), ORGANIZATION_ENABLED_COMMANDS.get('__all__')
443446
)
447+
commands_map = dict(COMMAND_CHOICES)
448+
return [
449+
(cmd, commands_map[cmd]) for cmd in allowed_commands if cmd in commands_map
450+
]
444451

445452
@classmethod
446453
def get_org_schema(self, organization_id=None):
@@ -459,19 +466,26 @@ def __str__(self):
459466

460467
def clean(self):
461468
self._verify_command_type_allowed()
469+
self._verify_connection()
462470
try:
463471
jsonschema.Draft4Validator(self._schema).validate(self.input)
464472
except SchemaError as e:
465473
raise ValidationError({'input': e.message})
466474

475+
def _verify_connection(self):
476+
"""Raises validation error if device has no connection and credentials."""
477+
if self.device and not self.device.deviceconnection_set.exists():
478+
raise ValidationError({'device': _('Device has no credentials assigned.')})
479+
467480
def _verify_command_type_allowed(self):
468481
"""Raises validation error if command type is not allowed."""
469482
# if device is not set, skip to avoid uncaught exception
470483
# (standard model validation will kick in)
471484
if not hasattr(self, 'device'):
472485
return
473-
if self.type not in self.get_org_choices(
474-
organization_id=self.device.organization_id
486+
487+
if self.type not in dict(
488+
self.get_org_allowed_commands(organization_id=self.device.organization_id)
475489
):
476490
raise ValidationError(
477491
{

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
@@ -563,6 +563,18 @@ def test_command_validation(self):
563563
],
564564
)
565565

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

787799
@mock.patch(_connect_path)
788800
@mock.patch.dict(COMMANDS, {})
789-
@mock.patch.dict(ORGANIZATION_ENABLED_COMMANDS, {'__all__': ('restart_network')})
801+
@mock.patch.dict(ORGANIZATION_ENABLED_COMMANDS, {'__all__': ('restart_network',)})
790802
@mock.patch(_exec_command_path)
791803
def test_execute_user_registered_command_without_input(
792804
self, mocked_exec_command, connect_mocked

0 commit comments

Comments
 (0)