فهرست منبع

fix(dcim): Prevent unsaved devices from matching all interfaces via Q()

An unsaved device (pk=None) in vc_interfaces() previously generated Q()
which matches every interface. Now returns Q(pk__in=[]) to match none,
fixing validation that incorrectly accepted IPs from other devices.

Fixes #23274
Martin Hauser 1 روز پیش
والد
کامیت
fcf1aceaa4
3فایلهای تغییر یافته به همراه85 افزوده شده و 2 حذف شده
  1. 2 1
      netbox/dcim/models/devices.py
  2. 22 0
      netbox/dcim/tests/test_api.py
  3. 61 1
      netbox/dcim/tests/test_models.py

+ 2 - 1
netbox/dcim/models/devices.py

@@ -1177,7 +1177,8 @@ class Device(
 
         :param if_master: If True, return VC member interfaces only if this Device is the VC master.
         """
-        filter = Q(device=self) if self.pk else Q()
+        # An unsaved device owns no interfaces. Q() would match all of them.
+        filter = Q(device=self) if self.pk else Q(pk__in=[])
         if self.virtual_chassis and (self.virtual_chassis.master == self or not if_master):
             filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
         return Interface.objects.filter(filter)

+ 22 - 0
netbox/dcim/tests/test_api.py

@@ -2812,6 +2812,28 @@ class DeviceTestCase(APIViewTestCases.APIViewTestCase):
 
         self.assertEqual(response.data['oob_ip']['dns_name'], 'oob.example.com')
 
+    @tag('regression')  # Issue #23274
+    def test_create_rejects_ips_of_other_devices(self):
+        """Creating a device with a primary or OOB IP on another device's interface returns HTTP 400."""
+        device = create_test_device('ip-owner-device')
+        interface = Interface.objects.create(device=device, name='eth0', type='other')
+        ip4 = IPAddress.objects.create(address='192.0.2.1/24', assigned_object=interface)
+        ip6 = IPAddress.objects.create(address='2001:db8::1/64', assigned_object=interface)
+
+        self.add_permissions('dcim.add_device')
+        for field, ip in (('primary_ip4', ip4), ('primary_ip6', ip6), ('oob_ip', ip4)):
+            with self.subTest(field=field):
+                data = {
+                    'device_type': device.device_type.pk,
+                    'role': device.role.pk,
+                    'site': device.site.pk,
+                    'name': f'new-device-{field}',
+                    field: ip.pk,
+                }
+                response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+                self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+                self.assertIn(field, response.data)
+
     def test_render_config_with_config_template_id(self):
         default_template = ConfigTemplate.objects.create(
             name='Default Template',

+ 61 - 1
netbox/dcim/tests/test_models.py

@@ -15,12 +15,13 @@ from dcim.choices import *
 from dcim.models import *
 from extras.events import serialize_for_event
 from extras.models import CustomField
-from ipam.models import Prefix
+from ipam.models import IPAddress, Prefix
 from netbox.choices import DiameterUnitChoices, FlowRateUnitChoices, WeightUnitChoices
 from netbox.context_managers import event_tracking
 from tenancy.models import Tenant
 from users.models import User
 from utilities.data import drange
+from utilities.testing import create_test_device
 from virtualization.models import Cluster, ClusterType
 
 
@@ -1000,6 +1001,65 @@ class DeviceTestCase(TestCase):
                 cluster=cluster
             ).full_clean()
 
+    @tag('regression')  # Ref: #23274
+    def test_vc_interfaces_unsaved_device(self):
+        """An unsaved device outside a virtual chassis matches no interfaces and runs no query."""
+        device = create_test_device('Device 1')
+        Interface.objects.create(device=device, name='eth0', type=InterfaceTypeChoices.TYPE_OTHER)
+        unsaved = Device(name='Device 2', site=device.site, device_type=device.device_type, role=device.role)
+
+        for if_master in (True, False):
+            with self.subTest(if_master=if_master), self.assertNumQueries(0):
+                self.assertEqual(list(unsaved.vc_interfaces(if_master=if_master)), [])
+
+    @tag('regression')  # Ref: #23274
+    def test_unsaved_device_rejects_ips_of_other_devices(self):
+        """An unsaved device rejects a primary or OOB IP assigned to another device's interface."""
+        device = create_test_device('Device 1')
+        interface = Interface.objects.create(device=device, name='eth0', type=InterfaceTypeChoices.TYPE_OTHER)
+        ip4 = IPAddress.objects.create(address='192.0.2.1/24', assigned_object=interface)
+        ip6 = IPAddress.objects.create(address='2001:db8::1/64', assigned_object=interface)
+        nat_ip4 = IPAddress.objects.create(address='198.51.100.1/24', nat_inside=ip4)
+
+        for field, ip in (('primary_ip4', ip4), ('primary_ip4', nat_ip4), ('primary_ip6', ip6), ('oob_ip', ip4)):
+            with self.subTest(field=field, ip=ip):
+                unsaved = Device(
+                    name='Device 2', site=device.site, device_type=device.device_type, role=device.role,
+                    **{field: ip}
+                )
+                with self.assertRaises(ValidationError) as cm:
+                    unsaved.full_clean()
+                self.assertIn(field, cm.exception.message_dict)
+
+    @tag('regression')  # Ref: #23274
+    def test_unsaved_device_accepts_ips_of_virtual_chassis_peers(self):
+        """An unsaved virtual chassis member accepts a peer's IP unless its interface is management-only."""
+        virtual_chassis = VirtualChassis.objects.create(name='Virtual Chassis 1')
+        peer = create_test_device('Device 1', virtual_chassis=virtual_chassis, vc_position=1)
+        interface = Interface.objects.create(device=peer, name='eth0', type=InterfaceTypeChoices.TYPE_OTHER)
+        mgmt_interface = Interface.objects.create(
+            device=peer, name='mgmt0', type=InterfaceTypeChoices.TYPE_OTHER, mgmt_only=True
+        )
+        ip4 = IPAddress.objects.create(address='192.0.2.1/24', assigned_object=interface)
+        ip6 = IPAddress.objects.create(address='2001:db8::1/64', assigned_object=interface)
+        mgmt_ip4 = IPAddress.objects.create(address='192.0.2.2/24', assigned_object=mgmt_interface)
+        common_kwargs = {
+            'name': 'Device 2',
+            'site': peer.site,
+            'device_type': peer.device_type,
+            'role': peer.role,
+            'virtual_chassis': virtual_chassis,
+            'vc_position': 2,
+        }
+
+        for field, ip in (('primary_ip4', ip4), ('primary_ip6', ip6), ('oob_ip', ip4)):
+            with self.subTest(field=field):
+                Device(**common_kwargs, **{field: ip}).full_clean()
+
+        with self.assertRaises(ValidationError) as cm:
+            Device(**common_kwargs, primary_ip4=mgmt_ip4).full_clean()
+        self.assertIn('primary_ip4', cm.exception.message_dict)
+
 
 class DeviceBayTestCase(TestCase):