From 3bd2dbea864c1c9a9dfd2290921d2ad33c0eabff Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 30 Mar 2026 15:35:28 -0700 Subject: [PATCH] Validate upload_file + data_source conflict for multipart requests DRF 3.16 Serializer.get_value() uses parse_html_dict() or empty for all HTML/multipart input. A flat key like data_source=2 produces an empty dict ({}), which is falsy, so it falls back to empty and the nested field is silently skipped. data.get('data_source') is therefore always None in multipart requests, bypassing the conflict check. Fix: also check self.initial_data for data_source and data_file in all three guards in validate(), so the raw submitted value is detected even when DRF's HTML parser drops the deserialized object. Add test_upload_with_data_source_fails to cover the multipart conflict path explicitly. Co-Authored-By: Claude Sonnet 4.6 --- netbox/extras/api/serializers_/scripts.py | 11 ++++++++--- netbox/extras/tests/test_api.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/netbox/extras/api/serializers_/scripts.py b/netbox/extras/api/serializers_/scripts.py index f4430dad5..94fa98fd8 100644 --- a/netbox/extras/api/serializers_/scripts.py +++ b/netbox/extras/api/serializers_/scripts.py @@ -44,15 +44,20 @@ class ScriptModuleSerializer(ValidatedModelSerializer): if upload_file is not None: data['upload_file'] = upload_file - if upload_file and data.get('data_file'): + # For multipart requests, nested serializer fields (data_source, data_file) are + # silently dropped by DRF's HTML parser, so also check initial_data for raw values. + has_data_file = data.get('data_file') or self.initial_data.get('data_file') + has_data_source = data.get('data_source') or self.initial_data.get('data_source') + + if upload_file and has_data_file: raise serializers.ValidationError( _("Cannot upload a file and sync from an existing data file.") ) - if upload_file and data.get('data_source'): + if upload_file and has_data_source: raise serializers.ValidationError( _("Cannot upload a file and sync from a data source.") ) - if self.instance is None and not upload_file and not data.get('data_file') and not data.get('data_source'): + if self.instance is None and not upload_file and not has_data_file and not has_data_source: raise serializers.ValidationError( _("Must upload a file or provide a data source or data file to sync.") ) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 7e4c49c77..86e2bfb65 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -1426,6 +1426,21 @@ class ScriptUploadTest(APITestCase): mock_storage.save.assert_called_once() self.assertTrue(ScriptModule.objects.filter(file_path='test_upload.py').exists()) + def test_upload_with_data_source_fails(self): + """Supplying both upload_file and data_source must be rejected.""" + self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile') + script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n pass\n" + upload_file = SimpleUploadedFile('test_upload.py', script_content, content_type='text/plain') + # data_source is intentionally a raw value to exercise the multipart path where DRF's + # nested-serializer HTML parser drops the field; validation must still catch the conflict. + response = self.client.post( + self.url_list, + {'upload_file': upload_file, 'data_source': 1}, + format='multipart', + **self.header, + ) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + def test_upload_script_module_without_file_fails(self): self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile') response = self.client.post(self.url_list, {}, format='json', **self.header)