فهرست منبع

Merge remote-tracking branch 'origin/main' into 23552-maintenance-mode-writes

# Conflicts:
#	netbox/netbox/tests/test_middleware.py
Brian Tiemann 7 ساعت پیش
والد
کامیت
5b695e63eb

+ 1 - 0
.github/VOUCHED.td

@@ -69,6 +69,7 @@ guibi1
 haeki
 harryajc
 hblandford
+himanchalkaushale
 ianb-mp
 ifoughal
 ipb-devops

+ 3 - 1
THREAT_MODEL.md

@@ -32,6 +32,7 @@ The central question when evaluating any NetBox security report is: **does the a
 | PostgreSQL database, Redis | **Trusted** | Assumed reachable only by the application. |
 | Infrastructure operators | **Trusted** | Shell/filesystem/DB access implies total control by design. |
 | Superusers (`is_superuser`) | **Trusted** | An active superuser bypasses all object-level permission checks. This is intentional. |
+| Users permitted to manage users (`users.add_user` or `users.change_user`) | **Trusted** | Can assign groups, object permissions, and superuser status to users, including themselves. Equivalent to a superuser by design. |
 | Users permitted to author code-bearing objects | **Trusted** | Holders of permissions to create/modify custom scripts, export templates, config templates, custom links, or webhooks (see below). |
 | Authenticated users **without** those permissions | **Untrusted** | Subject to full object-based permission enforcement. |
 | Unauthenticated / network-adjacent parties | **Untrusted** | Outside the supported deployment model entirely. |
@@ -96,6 +97,7 @@ The following are **not** treated as NetBox vulnerabilities. Most describe a pri
 | --- | --- |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
 | A permitted user runs code via a custom script, Jinja template, custom link, or webhook | Not a vulnerability | These features exist to execute user-authored logic; the required permission is trusted, operator-tier by design.                                                                                               |
 | A superuser modifies the database, reads secrets, or creates another superuser | Not a vulnerability | Superusers and infrastructure operators are trusted by design.                                                                                                                                                  |
+| A user permitted to add or change users grants themselves (or another user) superuser status or additional permissions | Not a vulnerability | User management permissions are administrative by design.                                                                                                                                                       |
 | A template author reads NetBox data they are otherwise permitted to view | Not a vulnerability | Template rendering with objects in scope is the purpose of the feature; the sandbox, not permission scoping, is the boundary.                                                                                   |
 | Missing login/request rate limiting | Out of scope | A deployment-layer concern, handled by the reverse proxy rather than the application.                                                                                                                           |
 | Client IP spoofing via `X-Forwarded-For` and similar headers | Out of scope | NetBox trusts the headers the reverse proxy sets; trustworthy client IP is a proxy responsibility ([`HTTP_CLIENT_IP_HEADERS`](https://netboxlabs.com/docs/netbox/configuration/system/#http_client_ip_headers)). |
@@ -112,7 +114,7 @@ The following are **not** treated as NetBox vulnerabilities. Most describe a pri
 | **R**epudiation | Changes are recorded via the changelog and journaling; event rules can emit notifications. |
 | **I**nformation disclosure | Object-based view permissions filter every queryset. Cross-boundary disclosure (e.g. API/GraphQL filter injection) is in scope; data legitimately visible to a template author is not. |
 | **D**enial of service | Request rate limiting and resource controls are a deployment/reverse-proxy responsibility, not the application's. |
-| **E**levation of privilege | The superuser flag is all-or-nothing and trusted. Any *unintended* escalation across the permission system (constraint bypass, action bypass) is in scope. |
+| **E**levation of privilege | The superuser flag is all-or-nothing and trusted, as are user management permissions. Any *unintended* escalation across the permission system (constraint bypass, action bypass) is in scope. |
 
 ## Triage & Severity
 

+ 29 - 11
netbox/dcim/models/cables.py

@@ -993,6 +993,9 @@ class CablePath(models.Model):
         is_complete = False
         is_active = True
         is_split = False
+        # Whether the trace fanned out across a multi-position front port without positional context
+        fanout = False
+        diverged = False
 
         logger.debug(f'Tracing cable path from {terminations}...')
 
@@ -1012,7 +1015,11 @@ class CablePath(models.Model):
                 not isinstance(terminations[0].parent_object, Circuit) and
                 not all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
             ):
-                raise UnsupportedCablePath(_("All mid-span terminations must have the same parent object"))
+                if not fanout:
+                    raise UnsupportedCablePath(_("All mid-span terminations must have the same parent object"))
+                # Fanned-out branches crossing different devices are not followed, even if they reconverge later
+                diverged = True
+                break
 
             # Check for a split path (e.g. rear port fanning out to multiple front ports with
             # different cables attached)
@@ -1177,15 +1184,13 @@ class CablePath(models.Model):
                     for rt in remote_terminations:
                         q_filter |= Q(front_port=rt, front_port_position__in=positions)
                     port_mappings = PortMapping.objects.filter(q_filter)
-                elif remote_terminations[0].positions > 1:
-                    is_split = True
-                    logger.debug(
-                        'Encountered front port mapped to multiple rear ports but position stack is empty; aborting '
-                        'trace.'
-                    )
-                    break
                 else:
-                    port_mappings = PortMapping.objects.filter(front_port__in=remote_terminations)
+                    # Without positional context, follow all mappings; branches must converge on one endpoint
+                    if any(t.positions > 1 for t in remote_terminations):
+                        fanout = True
+                    port_mappings = PortMapping.objects.filter(
+                        front_port__in=remote_terminations
+                    ).order_by('front_port', 'front_port_position')
                 if not port_mappings:
                     break
 
@@ -1257,7 +1262,10 @@ class CablePath(models.Model):
 
             else:
                 # Check for non-symmetric path
-                if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
+                # Fanned-out branches arriving over separate links reach different endpoints
+                if fanout and len(links) > 1:
+                    diverged = True
+                elif all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
                     is_complete = True
                 elif len(remote_terminations) == 0:
                     is_complete = False
@@ -1268,6 +1276,12 @@ class CablePath(models.Model):
                     logger.warning('Encountered an unsupported topology; aborting trace.')
                 break
 
+        if diverged:
+            # Keep the full trace so changes to any branch retrace this path
+            logger.debug('Front port branches diverged; treating path as split.')
+            is_complete = False
+            is_split = True
+
         return cls(
             path=path,
             is_complete=is_complete,
@@ -1372,7 +1386,8 @@ class CablePath(models.Model):
 
     def get_split_nodes(self):
         """
-        Return all available next segments in a split cable path.
+        Return all available next segments in a split cable path, or the diverging endpoints if the path fanned
+        out across a multi-position front port.
         """
         from circuits.models import CircuitTermination
         nodes = self.path_objects[-1]
@@ -1395,6 +1410,9 @@ class CablePath(models.Model):
             return [
                 ct.get_peer_termination() for ct in nodes
             ]
+        # Front port branches diverging to different endpoints
+        if isinstance(nodes[0], PathEndpoint):
+            return nodes
         return []
 
     def get_asymmetric_nodes(self):

+ 522 - 0
netbox/dcim/tests/test_cablepaths.py

@@ -2746,6 +2746,528 @@ class LegacyCablePathTestCase(BaseCablePathTestCase):
         cablepath.delete()
         self.assertIsNone(CablePath.objects.filter(pk=cablepath.pk).first())
 
+    def test_226_multiposition_frontport_to_converging_rear_ports(self):
+        """
+        [IF1] --C1-- [FP1:1] [RP1] --C2-- [RP3] [FP2] --C4-- [IF2]
+                     [FP1:2] [RP2] --C3-- [RP4] [FP3]
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3')
+        rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2)
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=1,
+                rear_port=rearport1, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=2,
+                rear_port=rearport2, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport2, front_port_position=1,
+                rear_port=rearport3, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport3, front_port_position=1,
+                rear_port=rearport4, rear_port_position=1,
+            ),
+        ])
+
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[rearport3])
+        cable2.save()
+        cable3 = Cable(a_terminations=[rearport2], b_terminations=[rearport4])
+        cable3.save()
+        cable4 = Cable(a_terminations=[frontport2, frontport3], b_terminations=[interface2])
+        cable4.save()
+
+        path1 = self.assertPathExists(
+            (
+                interface1, cable1, frontport1, (rearport1, rearport2), (cable2, cable3),
+                (rearport3, rearport4), (frontport2, frontport3), cable4, interface2
+            ),
+            is_complete=True,
+            is_active=True,
+            is_split=False
+        )
+        path2 = self.assertPathExists(
+            (
+                interface2, cable4, (frontport2, frontport3), (rearport3, rearport4), (cable2, cable3),
+                (rearport1, rearport2), frontport1, cable1, interface1
+            ),
+            is_complete=True,
+            is_active=True,
+            is_split=False
+        )
+        self.assertEqual(CablePath.objects.count(), 2)
+        interface1.refresh_from_db()
+        interface2.refresh_from_db()
+        self.assertPathIsSet(interface1, path1)
+        self.assertPathIsSet(interface2, path2)
+
+    def test_227_multiposition_frontport_to_diverging_rear_ports(self):
+        """
+        [IF1] --C1-- [FP1:1] [RP1] --C2-- [IF2]
+                     [FP1:2] [RP2] --C3-- [IF3]
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        interface3 = Interface.objects.create(device=self.device, name='Interface 3')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2)
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=1,
+                rear_port=rearport1, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=2,
+                rear_port=rearport2, rear_port_position=1,
+            ),
+        ])
+
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[interface2])
+        cable2.save()
+        cable3 = Cable(a_terminations=[rearport2], b_terminations=[interface3])
+        cable3.save()
+
+        # Branches reaching different endpoints are still treated as a split
+        path1 = self.assertPathExists(
+            (
+                interface1, cable1, frontport1, (rearport1, rearport2), (cable2, cable3),
+                (interface2, interface3)
+            ),
+            is_complete=False,
+            is_split=True
+        )
+        self.assertEqual(path1.get_split_nodes(), [interface2, interface3])
+        self.assertPathExists(
+            (interface2, cable2, rearport1, frontport1, cable1, interface1),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertPathExists(
+            (interface3, cable3, rearport2, frontport1, cable1, interface1),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(CablePath.objects.count(), 3)
+
+    def test_228_multiposition_frontport_to_crossed_rear_ports_on_remote_device(self):
+        """
+        Device A                                    Device B
+        [IF1] --C1-- [FP1:1] [RP1] --C2-- [RP4] [FP3] --C4-- [IF2]
+                     [FP1:2] [RP2] --C3-- [RP3] [FP2] --C4--
+        """
+        device_b = Device.objects.create(
+            site=self.site, device_type=self.device.device_type, role=self.device.role, name='Device B'
+        )
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=device_b, name='Interface 2')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=device_b, name='Rear Port 3')
+        rearport4 = RearPort.objects.create(device=device_b, name='Rear Port 4')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2)
+        frontport2 = FrontPort.objects.create(device=device_b, name='Front Port 2')
+        frontport3 = FrontPort.objects.create(device=device_b, name='Front Port 3')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=1,
+                rear_port=rearport1, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=2,
+                rear_port=rearport2, rear_port_position=1,
+            ),
+            PortMapping(
+                device=device_b, front_port=frontport2, front_port_position=1,
+                rear_port=rearport3, rear_port_position=1,
+            ),
+            PortMapping(
+                device=device_b, front_port=frontport3, front_port_position=1,
+                rear_port=rearport4, rear_port_position=1,
+            ),
+        ])
+
+        # TX/RX crossed between the two devices
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[rearport4])
+        cable2.save()
+        cable3 = Cable(a_terminations=[rearport2], b_terminations=[rearport3])
+        cable3.save()
+        cable4 = Cable(a_terminations=[frontport2, frontport3], b_terminations=[interface2])
+        cable4.save()
+
+        path1 = self.assertPathExists(
+            (
+                interface1, cable1, frontport1, (rearport1, rearport2), (cable2, cable3),
+                (rearport4, rearport3), (frontport2, frontport3), cable4, interface2
+            ),
+            is_complete=True,
+            is_active=True,
+            is_split=False
+        )
+        path2 = self.assertPathExists(
+            (
+                interface2, cable4, (frontport2, frontport3), (rearport3, rearport4), (cable3, cable2),
+                (rearport1, rearport2), frontport1, cable1, interface1
+            ),
+            is_complete=True,
+            is_active=True,
+            is_split=False
+        )
+        self.assertEqual(CablePath.objects.count(), 2)
+        interface1.refresh_from_db()
+        interface2.refresh_from_db()
+        self.assertPathIsSet(interface1, path1)
+        self.assertPathIsSet(interface2, path2)
+        self.assertEqual(interface1.connected_endpoints, [interface2])
+        self.assertEqual(interface2.connected_endpoints, [interface1])
+
+    def test_229_multiposition_frontport_to_rear_ports_on_different_devices(self):
+        """
+        [IF1] --C1-- [FP1:1] [RP1] --C2-- [RP3] [FP2]  (Device A)
+                     [FP1:2] [RP2] --C3-- [RP4] [FP3]  (Device B)
+        """
+        device_a = Device.objects.create(
+            site=self.site, device_type=self.device.device_type, role=self.device.role, name='Device A'
+        )
+        device_b = Device.objects.create(
+            site=self.site, device_type=self.device.device_type, role=self.device.role, name='Device B'
+        )
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=device_a, name='Rear Port 3')
+        rearport4 = RearPort.objects.create(device=device_b, name='Rear Port 4')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2)
+        frontport2 = FrontPort.objects.create(device=device_a, name='Front Port 2')
+        frontport3 = FrontPort.objects.create(device=device_b, name='Front Port 3')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=1,
+                rear_port=rearport1, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=2,
+                rear_port=rearport2, rear_port_position=1,
+            ),
+            PortMapping(
+                device=device_a, front_port=frontport2, front_port_position=1,
+                rear_port=rearport3, rear_port_position=1,
+            ),
+            PortMapping(
+                device=device_b, front_port=frontport3, front_port_position=1,
+                rear_port=rearport4, rear_port_position=1,
+            ),
+        ])
+
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[rearport3], status=LinkStatusChoices.STATUS_PLANNED)
+        cable2.save()
+        cable3 = Cable(a_terminations=[rearport2], b_terminations=[rearport4])
+        cable3.save()
+
+        # Branches crossing different devices are not followed past the remote rear ports
+        path1 = self.assertPathExists(
+            (interface1, cable1, frontport1, (rearport1, rearport2), (cable2, cable3), (rearport3, rearport4)),
+            is_complete=False,
+            is_active=False,
+            is_split=True
+        )
+        self.assertEqual(CablePath.objects.count(), 1)
+        interface1.refresh_from_db()
+        self.assertPathIsSet(interface1, path1)
+
+    def test_230_multiposition_frontport_with_uncabled_rear_port(self):
+        """
+        [IF1] --C1-- [FP1:1] [RP1] --C2-- [RP3] [FP2] --C3-- [IF2]
+                     [FP1:2] [RP2]
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2)
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=1,
+                rear_port=rearport1, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=2,
+                rear_port=rearport2, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport2, front_port_position=1,
+                rear_port=rearport3, rear_port_position=1,
+            ),
+        ])
+
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[rearport3])
+        cable2.save()
+        cable3 = Cable(a_terminations=[frontport2], b_terminations=[interface2])
+        cable3.save()
+
+        # The uncabled branch is flagged as split (asymmetric), as for multi-termination cables
+        path1 = self.assertPathExists(
+            (
+                interface1, cable1, frontport1, (rearport1, rearport2), cable2, rearport3, frontport2, cable3,
+                interface2
+            ),
+            is_complete=True,
+            is_active=True,
+            is_split=True
+        )
+        path2 = self.assertPathExists(
+            (interface2, cable3, frontport2, rearport3, cable2, rearport1, frontport1, cable1, interface1),
+            is_complete=True,
+            is_active=True,
+            is_split=False
+        )
+        self.assertEqual(CablePath.objects.count(), 2)
+        interface1.refresh_from_db()
+        interface2.refresh_from_db()
+        self.assertPathIsSet(interface1, path1)
+        self.assertPathIsSet(interface2, path2)
+        self.assertEqual(interface1.connected_endpoints, [interface2])
+
+    def test_231_multiposition_frontport_rewire_diverged_to_converged(self):
+        """
+        [IF1] --C1-- [FP1:1] [RP1] --C2-- [RP3] [FP2] --C4-- [IF2]
+                     [FP1:2] [RP2] --C3-- [RP4] [FP3] --C5-- [IF3]
+        Rewired so that FP2 and FP3 both connect to IF2, then back again.
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        interface3 = Interface.objects.create(device=self.device, name='Interface 3')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3')
+        rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2)
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=1,
+                rear_port=rearport1, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=2,
+                rear_port=rearport2, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport2, front_port_position=1,
+                rear_port=rearport3, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport3, front_port_position=1,
+                rear_port=rearport4, rear_port_position=1,
+            ),
+        ])
+
+        def assert_path_is_current(interface):
+            interface.refresh_from_db()
+            stored = interface.path
+            traced = CablePath.from_origin([interface])
+            self.assertEqual(
+                (stored.path, stored.is_complete, stored.is_active, stored.is_split),
+                (traced.path, traced.is_complete, traced.is_active, traced.is_split),
+            )
+            return stored
+
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[rearport3])
+        cable2.save()
+        cable3 = Cable(a_terminations=[rearport2], b_terminations=[rearport4])
+        cable3.save()
+        cable4 = Cable(a_terminations=[frontport2], b_terminations=[interface2])
+        cable4.save()
+        cable5 = Cable(a_terminations=[frontport3], b_terminations=[interface3])
+        cable5.save()
+        path1 = assert_path_is_current(interface1)
+        self.assertTrue(path1.is_split)
+        self.assertFalse(path1.is_complete)
+
+        # Removing one branch's endpoint leaves the other branch complete (and asymmetric)
+        cable5.delete()
+        path1 = assert_path_is_current(interface1)
+        self.assertTrue(path1.is_complete)
+
+        # Converge both branches on a single endpoint
+        cable4.delete()
+        cable6 = Cable(a_terminations=[frontport2, frontport3], b_terminations=[interface2])
+        cable6.save()
+        path1 = assert_path_is_current(interface1)
+        self.assertTrue(path1.is_complete)
+        self.assertFalse(path1.is_split)
+        self.assertEqual(Interface.objects.get(pk=interface1.pk).connected_endpoints, [interface2])
+        self.assertEqual(Interface.objects.get(pk=interface2.pk).connected_endpoints, [interface1])
+
+        # Diverge the branches again
+        cable6.delete()
+        cable7 = Cable(a_terminations=[frontport2], b_terminations=[interface2])
+        cable7.save()
+        cable8 = Cable(a_terminations=[frontport3], b_terminations=[interface3])
+        cable8.save()
+        path1 = assert_path_is_current(interface1)
+        self.assertTrue(path1.is_split)
+        self.assertFalse(path1.is_complete)
+        self.assertEqual(Interface.objects.get(pk=interface1.pk).connected_endpoints, [])
+
+    def test_232_multiposition_frontport_reconverged_to_multiple_interfaces(self):
+        """
+        [IF1] --C1-- [FP1:1] [RP1:1] --C2-- [RP2:1] [FP2:1] --C3-- [IF2]
+                     [FP1:2] [RP1:2]        [RP2:2] [FP2:2]        [IF3]
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        interface3 = Interface.objects.create(device=self.device, name='Interface 3')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2)
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2)
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2)
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2', positions=2)
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport, front_port_position=position,
+                rear_port=rearport, rear_port_position=position,
+            )
+            for frontport, rearport in ((frontport1, rearport1), (frontport2, rearport2))
+            for position in (1, 2)
+        ])
+
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[rearport2])
+        cable2.save()
+        cable3 = Cable(a_terminations=[frontport2], b_terminations=[interface2, interface3])
+        cable3.save()
+
+        # Branches reconverge on RP1, so the final multi-termination cable is not a split
+        self.assertPathExists(
+            (
+                interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3,
+                (interface2, interface3)
+            ),
+            is_complete=True,
+            is_active=True,
+            is_split=False
+        )
+
+    def test_233_mixed_position_frontports_to_diverging_rear_ports(self):
+        """
+        [IF1] --C1-- [FP1]   [RP1] --C2-- [IF3]
+        [IF2]        [FP2:1] [RP2] --C3-- [IF4]
+                     [FP2:2] [RP3] --C4-- [IF5]
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        interface3 = Interface.objects.create(device=self.device, name='Interface 3')
+        interface4 = Interface.objects.create(device=self.device, name='Interface 4')
+        interface5 = Interface.objects.create(device=self.device, name='Interface 5')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2', positions=2)
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=1,
+                rear_port=rearport1, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport2, front_port_position=1,
+                rear_port=rearport2, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport2, front_port_position=2,
+                rear_port=rearport3, rear_port_position=1,
+            ),
+        ])
+
+        # The single-position front port is the first termination on the cable
+        cable1 = Cable(a_terminations=[interface1, interface2], b_terminations=[frontport1, frontport2])
+        cable1.save()
+        for rearport, interface in ((rearport1, interface3), (rearport2, interface4), (rearport3, interface5)):
+            Cable(a_terminations=[rearport], b_terminations=[interface]).save()
+
+        interface1.refresh_from_db()
+        self.assertFalse(interface1.path.is_complete)
+        self.assertTrue(interface1.path.is_split)
+
+    def test_234_multiposition_frontport_reconverged_then_diverging(self):
+        """
+        [IF1] --C1-- [FP1:1] [RP1:1] --C2-- [RP2:1] [FP2] --C3-- [IF2]
+                     [FP1:2] [RP1:2]        [RP2:2] [FP3] --C4-- [IF3]
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        interface3 = Interface.objects.create(device=self.device, name='Interface 3')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2)
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2)
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2)
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=1,
+                rear_port=rearport1, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=2,
+                rear_port=rearport1, rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport2, front_port_position=1,
+                rear_port=rearport2, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport3, front_port_position=1,
+                rear_port=rearport2, rear_port_position=2,
+            ),
+        ])
+
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[rearport2])
+        cable2.save()
+        cable3 = Cable(a_terminations=[frontport2], b_terminations=[interface2])
+        cable3.save()
+        cable4 = Cable(a_terminations=[frontport3], b_terminations=[interface3])
+        cable4.save()
+
+        # Reconverging on RP1 does not hide the branches diverging again at RP2
+        path1 = self.assertPathExists(
+            (
+                interface1, cable1, frontport1, rearport1, cable2, rearport2, (frontport2, frontport3),
+                (cable3, cable4), (interface2, interface3)
+            ),
+            is_complete=False,
+            is_split=True
+        )
+        self.assertEqual(path1.get_split_nodes(), [interface2, interface3])
+        interface1.refresh_from_db()
+        self.assertEqual(interface1.connected_endpoints, [])
+
     def test_301_create_path_via_existing_cable(self):
         """
         [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]

+ 11 - 0
netbox/netbox/middleware.py

@@ -41,6 +41,8 @@ class CommonMiddleware(DjangoCommonMiddleware):
     body (clients downgrade to GET on a 302) or raise a RuntimeError when
     DEBUG is enabled. Letting the original 404 propagate gives the caller a
     clear, actionable error instead.
+
+    Also reports 404 responses to Sentry after APPEND_SLASH handling.
     """
     UNSAFE_METHODS = frozenset(('DELETE', 'PATCH', 'POST', 'PUT'))
 
@@ -49,6 +51,15 @@ class CommonMiddleware(DjangoCommonMiddleware):
             return False
         return super().should_redirect_with_slash(request)
 
+    def process_response(self, request, response):
+        response = super().process_response(request, response)
+
+        if settings.SENTRY_ENABLED and response.status_code == 404 and getattr(request, '_sentry_report_404', False):
+            from sentry_sdk import capture_message
+            capture_message("Page not found", level="error")
+
+        return response
+
 
 class CoreMiddleware:
 

+ 48 - 1
netbox/netbox/tests/test_middleware.py

@@ -1,4 +1,5 @@
 import json
+import sys
 from contextlib import contextmanager
 from types import SimpleNamespace
 from unittest.mock import Mock, patch
@@ -20,7 +21,7 @@ from netbox.config import clear_config
 from netbox.middleware import CoreMiddleware, MaintenanceModeMiddleware
 from users.constants import TOKEN_PREFIX
 from users.models import Token, User
-from utilities.testing import TestCase
+from utilities.testing import TestCase, disable_warnings
 
 
 class CoreMiddlewareTestCase(TestCase):
@@ -450,3 +451,49 @@ class MaintenanceModeTestCase(TransactionTestCase):
 
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         mock_backend.return_value.populate_user.assert_not_called()
+
+
+@override_settings(SENTRY_ENABLED=True)
+class SentryNotFoundReportingTestCase(TestCase):
+
+    def setUp(self):
+        super().setUp()
+        self.sentry_sdk = Mock()
+        patcher = patch.dict(sys.modules, {'sentry_sdk': self.sentry_sdk})
+        patcher.start()
+        self.addCleanup(patcher.stop)
+
+    def test_404_reported_to_sentry(self):
+        with disable_warnings('django.request'):
+            response = self.client.get('/nonexistent-path/')
+
+        self.assertEqual(response.status_code, 404)
+        self.sentry_sdk.capture_message.assert_called_once_with('Page not found', level='error')
+
+    def test_append_slash_redirect_not_reported_to_sentry(self):
+        url = reverse('api-status')
+
+        for method in ('get', 'head'):
+            with self.subTest(method=method):
+                response = getattr(self.client, method)(url.rstrip('/'))
+
+                self.assertEqual(response.status_code, 301)
+                self.assertEqual(response.headers['Location'], url)
+                self.sentry_sdk.capture_message.assert_not_called()
+
+    def test_unsafe_api_request_without_slash_reported_to_sentry(self):
+        with disable_warnings('django.request'):
+            response = self.client.post(
+                reverse('api-status').rstrip('/'), data={}, content_type='application/json'
+            )
+
+        self.assertEqual(response.status_code, 404)
+        self.sentry_sdk.capture_message.assert_called_once_with('Page not found', level='error')
+
+    @override_settings(SENTRY_ENABLED=False)
+    def test_404_not_reported_when_sentry_disabled(self):
+        with disable_warnings('django.request'):
+            response = self.client.get('/nonexistent-path/')
+
+        self.assertEqual(response.status_code, 404)
+        self.sentry_sdk.capture_message.assert_not_called()

+ 3 - 4
netbox/netbox/views/errors.py

@@ -31,11 +31,10 @@ class StaticMediaFailureView(View):
 
 def handler_404(request, exception):
     """
-    Wrap Django's default 404 handler to enable Sentry reporting.
+    Wrap Django's default 404 handler to enable Sentry reporting. The report is sent by CommonMiddleware
+    once the final response is known (it may be replaced with an APPEND_SLASH redirect).
     """
-    if settings.SENTRY_ENABLED:
-        from sentry_sdk import capture_message
-        capture_message("Page not found", level="error")
+    request._sentry_report_404 = True
 
     return page_not_found(request, exception)
 

+ 61 - 51
netbox/translations/en/LC_MESSAGES/django.po

@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-11-10 05:01+0000\n"
+"POT-Creation-Date: 2026-11-11 05:01+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -1666,7 +1666,7 @@ msgid "virtual circuit termination"
 msgstr ""
 
 #: netbox/circuits/models/virtual_circuits.py:160
-#: netbox/dcim/models/module_moves.py:380
+#: netbox/dcim/models/module_moves.py:381
 msgid "virtual circuit terminations"
 msgstr ""
 
@@ -3252,18 +3252,12 @@ msgid ""
 "type and move the module as separate operations."
 msgstr ""
 
-#: netbox/dcim/api/serializers_/devices.py:281 netbox/dcim/forms/common.py:159
-msgid ""
-"Cannot install module with placeholder values in a module bay with no "
-"position defined."
-msgstr ""
-
-#: netbox/dcim/api/serializers_/devices.py:292 netbox/dcim/forms/common.py:171
+#: netbox/dcim/api/serializers_/devices.py:287 netbox/dcim/forms/common.py:172
 #, python-brace-format
 msgid "Cannot adopt {model} {name} as it already belongs to a module"
 msgstr ""
 
-#: netbox/dcim/api/serializers_/devices.py:300 netbox/dcim/forms/common.py:180
+#: netbox/dcim/api/serializers_/devices.py:295 netbox/dcim/forms/common.py:181
 #, python-brace-format
 msgid "A {model} named {name} already exists"
 msgstr ""
@@ -7532,7 +7526,7 @@ msgid "untagged VLAN"
 msgstr ""
 
 #: netbox/dcim/models/device_components.py:849
-#: netbox/dcim/models/module_moves.py:384
+#: netbox/dcim/models/module_moves.py:385
 msgid "tagged VLANs"
 msgstr ""
 
@@ -7755,7 +7749,7 @@ msgid "module bays"
 msgstr ""
 
 #: netbox/dcim/models/device_components.py:1711
-#: netbox/dcim/models/module_moves.py:755 netbox/dcim/models/modules.py:503
+#: netbox/dcim/models/module_moves.py:756 netbox/dcim/models/modules.py:503
 msgid "A module bay cannot belong to a module installed within it."
 msgstr ""
 
@@ -8354,116 +8348,116 @@ msgstr ""
 msgid "Module bay hierarchy contains a cycle."
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:357
+#: netbox/dcim/models/module_moves.py:358
 msgid "Device was deleted before the move could be saved."
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:376
+#: netbox/dcim/models/module_moves.py:377
 msgid "IP addresses assigned"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:377 netbox/ipam/models/fhrp.py:119
+#: netbox/dcim/models/module_moves.py:378 netbox/ipam/models/fhrp.py:119
 msgid "FHRP group assignments"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:378 netbox/vpn/models/tunnels.py:140
+#: netbox/dcim/models/module_moves.py:379 netbox/vpn/models/tunnels.py:140
 msgid "tunnel terminations"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:379 netbox/vpn/models/l2vpn.py:117
+#: netbox/dcim/models/module_moves.py:380 netbox/vpn/models/l2vpn.py:117
 msgid "L2VPN terminations"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:381 netbox/wireless/models.py:195
+#: netbox/dcim/models/module_moves.py:382 netbox/wireless/models.py:195
 msgid "wireless links"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:382
+#: netbox/dcim/models/module_moves.py:383
 msgid "wireless LAN assignments"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:383
+#: netbox/dcim/models/module_moves.py:384
 msgid "an untagged VLAN"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:385
+#: netbox/dcim/models/module_moves.py:386
 msgid "a Q-in-Q service VLAN"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:386
+#: netbox/dcim/models/module_moves.py:387
 msgid "a VLAN translation policy"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:387
+#: netbox/dcim/models/module_moves.py:388
 msgid "VDC assignments"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:388
+#: netbox/dcim/models/module_moves.py:389
 msgid "a VRF assignment"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:436
+#: netbox/dcim/models/module_moves.py:437
 #, python-brace-format
 msgid "{description} (e.g. {names})"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:463
+#: netbox/dcim/models/module_moves.py:464
 #, python-brace-format
 msgid "{count} cabled or connection-marked {type}"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:474
+#: netbox/dcim/models/module_moves.py:475
 #, python-brace-format
 msgid "{count} interfaces with {label}"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:493
+#: netbox/dcim/models/module_moves.py:494
 #, python-brace-format
 msgid ""
 "{count} parent, bridge, or LAG interface relations crossing the moved "
 "module's boundary"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:511
+#: netbox/dcim/models/module_moves.py:512
 #, python-brace-format
 msgid "{count} power outlet relations crossing the moved module's boundary"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:532
+#: netbox/dcim/models/module_moves.py:533
 #, python-brace-format
 msgid "{count} cooling outflow relations crossing the moved module's boundary"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:550
+#: netbox/dcim/models/module_moves.py:551
 #, python-brace-format
 msgid "{count} front/rear port mappings crossing the moved module's boundary"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:573
+#: netbox/dcim/models/module_moves.py:574
 #, python-brace-format
 msgid "{count} components with attached inventory items"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:581
+#: netbox/dcim/models/module_moves.py:582
 #, python-brace-format
 msgid ""
 "This module cannot be moved to a different device because the moved "
 "components have active related objects: {blockers}."
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:595
+#: netbox/dcim/models/module_moves.py:596
 #, python-brace-format
 msgid "Moving this module would create more than one {type} named {name}."
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:609
+#: netbox/dcim/models/module_moves.py:610
 #, python-brace-format
 msgid ""
 "Moving this module would conflict with {count} existing {type} on device "
 "{device} (e.g. {sample})."
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:622
+#: netbox/dcim/models/module_moves.py:623
 #, python-brace-format
 msgid ""
 "Moving this module would rename {old_name} to {new_name}, which is the "
@@ -8471,42 +8465,42 @@ msgid ""
 "manually before moving."
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:639
+#: netbox/dcim/models/module_moves.py:640
 #, python-brace-format
 msgid ""
 "Moving this module would create more than one module bay named {name} within "
 "the same module."
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:652
+#: netbox/dcim/models/module_moves.py:653
 #, python-brace-format
 msgid ""
 "Moving this module would rename module bay {old_name} to {new_name}, which "
 "is the current name of another moved module bay in the same module."
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:681
+#: netbox/dcim/models/module_moves.py:682
 #, python-brace-format
 msgid ""
 "Moving this module would exceed the maximum field length for the following: "
 "{offenders}."
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:698
+#: netbox/dcim/models/module_moves.py:699
 #, python-brace-format
 msgid ""
 "{component}: new {field} {value} ({length} characters) exceeds the {limit}-"
 "character limit"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:718
+#: netbox/dcim/models/module_moves.py:719
 #, python-brace-format
 msgid ""
 "{component}: the matched template's {field} cannot be resolved for the "
 "destination bay hierarchy"
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:727
+#: netbox/dcim/models/module_moves.py:728
 #, python-brace-format
 msgid ""
 "Moving this module would require template-derived values that cannot be "
@@ -8515,20 +8509,20 @@ msgid ""
 "before moving."
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:736
+#: netbox/dcim/models/module_moves.py:737
 msgid "The target module bay no longer exists."
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:740
+#: netbox/dcim/models/module_moves.py:741
 #, python-brace-format
 msgid "Module bay {module_bay} does not belong to device {device}."
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:745 netbox/dcim/models/modules.py:467
+#: netbox/dcim/models/module_moves.py:746 netbox/dcim/models/modules.py:467
 msgid "Cannot install a module in a disabled module bay."
 msgstr ""
 
-#: netbox/dcim/models/module_moves.py:750 netbox/dcim/models/modules.py:476
+#: netbox/dcim/models/module_moves.py:751 netbox/dcim/models/modules.py:476
 #, python-brace-format
 msgid "Module bay {module_bay} is already occupied by module {module}."
 msgstr ""
@@ -9613,6 +9607,12 @@ msgid ""
 "levels deep but {tokens} placeholders given."
 msgstr ""
 
+#: netbox/dcim/utils.py:105
+msgid ""
+"Cannot install module with placeholder values in a module bay with no "
+"position defined."
+msgstr ""
+
 #: netbox/dcim/views.py:154
 #, python-brace-format
 msgid "Disconnected {count} {type}"
@@ -14415,7 +14415,7 @@ msgstr ""
 msgid "{failed_count} of {total} objects could not be deleted."
 msgstr ""
 
-#: netbox/netbox/api/viewsets/mixins.py:816
+#: netbox/netbox/api/viewsets/mixins.py:813
 #, python-brace-format
 msgid "Unable to delete: {n} dependent object(s) prevent deletion."
 msgstr ""
@@ -18806,16 +18806,26 @@ msgstr ""
 msgid "flow rate"
 msgstr ""
 
-#: netbox/utilities/error_handlers.py:31
+#: netbox/utilities/error_handlers.py:59
 #, python-brace-format
 msgid ""
 "Unable to delete <strong>{objects}</strong>. {count} dependent objects were "
 "found: "
 msgstr ""
 
-#: netbox/utilities/error_handlers.py:33
-msgid "More than 50"
-msgstr ""
+#: netbox/utilities/error_handlers.py:73
+#, python-brace-format
+msgid "{count} more object"
+msgid_plural "{count} more objects"
+msgstr[0] ""
+msgstr[1] ""
+
+#: netbox/utilities/error_handlers.py:79
+#, python-brace-format
+msgid "{count} object you do not have permission to view"
+msgid_plural "{count} objects you do not have permission to view"
+msgstr[0] ""
+msgstr[1] ""
 
 #: netbox/utilities/export.py:28 netbox/utilities/export.py:62
 #, python-brace-format