VYPR
Medium severity6.5GHSA Advisory· Published May 13, 2026

Nautobot: Object bulk rename UI actions vulnerable to denial of service by crafted regular expression (REDoS)

CVE-2026-44796

Description

Impact

Nautobot UI object-bulk-rename endpoints (for example, /dcim/interfaces/rename/) were vulnerable to application-wide denial of service via maliciously crafted regular expressions in the find field in combination with the use_regex flag.

Patches

A general-purpose timeout has been added to these endpoints in Nautobot v2.4.33 and v3.1.2, which ensures that the request will fail early with an appropriate message if regular expression evaluation takes more than a short period of time, instead of continuing to execute for an indefinite duration.

Workarounds

No known workaround has been identified at this time.

References

  • 2.4.33 (<a href="https://github.com/nautobot/nautobot/commit/c2b766966d814a7141f62c7bc90c85fefb7892ee">patch</a>)
  • 3.1.2 (<a href="https://github.com/nautobot/nautobot/commit/5a30d0916953afbeedd24a784709e762cc3879cd">patch</a>)

Affected products

1

Patches

2
5a30d0916953

Merge commit from fork

https://github.com/nautobot/nautobotGlenn MatthewsMay 7, 2026via ghsa
9 files changed · +186 234
  • changes/GHSA-qrpw-gjvh-x5gm.dependencies+1 0 added
    @@ -0,0 +1 @@
    +Added `regex>=2026.4.4` as a dependency. (Previously it was a development-only dependency.)
    
  • changes/GHSA-qrpw-gjvh-x5gm.housekeeping+1 0 added
    @@ -0,0 +1 @@
    +Replaced bespoke `bulk_rename` actions on `ModuleBayUIViewSet` and `ModuleBayTemplateUIViewSet` with the generic `ObjectBulkRenameViewMixin`.
    
  • changes/GHSA-qrpw-gjvh-x5gm.security+1 0 added
    @@ -0,0 +1 @@
    +Added a timeout to `bulk-rename` views (both legacy `BulkRenameView` and viewset `ObjectBulkRenameViewMixin`) when doing regular-expression-based bulk renames to protect against denial-of-service (REDoS) due to an overly-complex or maliciously crafted regular expression provided by the user (GHSA-qrpw-gjvh-x5gm).
    
  • nautobot/core/testing/views.py+108 77 modified
    @@ -1,5 +1,6 @@
     import contextlib
     import re
    +import signal
     import traceback
     from typing import Optional, Sequence
     from unittest import mock, skipIf
    @@ -17,7 +18,7 @@
     from django.test import override_settings, tag, TestCase as _TestCase
     from django.test.testcases import assert_and_parse_html
     from django.test.utils import CaptureQueriesContext
    -from django.urls import NoReverseMatch, resolve, reverse
    +from django.urls import NoReverseMatch, reverse
     from django.utils.html import escape, format_html
     from django.utils.http import urlencode
     from django.utils.text import slugify
    @@ -39,8 +40,8 @@
     from nautobot.core.testing.utils import extract_page_title
     from nautobot.core.ui.object_detail import ObjectsTablePanel
     from nautobot.core.utils import lookup
    -from nautobot.core.views.mixins import NautobotViewSetMixin, ObjectBulkRenameViewMixin, PERMISSIONS_ACTION_MAP
    -from nautobot.dcim.models.device_components import ComponentModel, ModularComponentModel
    +from nautobot.core.views.mixins import NautobotViewSetMixin, PERMISSIONS_ACTION_MAP
    +from nautobot.dcim.models.device_components import ModularComponentModel
     from nautobot.extras import choices as extras_choices, models as extras_models, querysets as extras_querysets
     from nautobot.extras.forms import CustomFieldModelFormMixin, RelationshipModelFormMixin
     from nautobot.extras.models import CustomFieldModel, RelationshipModel
    @@ -1839,23 +1840,9 @@ class BulkRenameObjectsViewTestCase(ModelViewTestCase):
                 "replace": "\\1X",  # Append an X to the original value
                 "use_regex": True,
             }
    -
    -        def _has_redos_protection(self):
    -            """Check if this model's bulk_rename uses the _is_safe_regex check from ObjectBulkRenameViewMixin.
    -
    -            Returns False for old-style BulkRenameView and for viewsets that override _bulk_rename
    -            (e.g. ModuleBayCommonViewSetMixin).
    -            """
    -            try:
    -                url = self._get_url("bulk_rename")
    -            except NoReverseMatch:
    -                self.skipTest(f"{self.model.__name__} does not have a bulk_rename route")
    -            resolved = resolve(url)
    -            view_cls = getattr(resolved.func, "cls", None)
    -            if view_cls is None or not issubclass(view_cls, ObjectBulkRenameViewMixin):
    -                return False
    -            # Check that _bulk_rename isn't overridden by another mixin
    -            return view_cls._bulk_rename is ObjectBulkRenameViewMixin._bulk_rename
    +        # Generally optional, used in legacy `test_bulk_rename` test for device-component models only.
    +        selected_objects: Optional[list[Model]] = None
    +        selected_objects_parent_name: Optional[str] = None
     
             def test_bulk_rename_objects_without_permission(self):
                 try:
    @@ -1947,47 +1934,129 @@ def test_bulk_rename_objects_with_constrained_permission(self):
                     self.assertEqual(instance.name, expected_name)
     
             @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
    -        def test_bulk_rename_regex_redos_protection(self):
    +        def test_bulk_rename(self):
    +            """Legacy test, originally specific to DeviceComponentViewTestCase."""
                 try:
                     self._get_url("bulk_rename")
                 except NoReverseMatch:
                     self.skipTest(f"{self.model.__name__} does not have a bulk_rename route")
    -            """A pattern with nested quantifiers should be rejected (new UIViewSet mixin only)."""
    -            if not self._has_redos_protection():
    -                self.skipTest("ReDoS protection only applies to ObjectBulkRenameViewMixin (not overridden)")
    +            self.add_permissions(f"{self.model._meta.app_label}.change_{self.model._meta.model_name}")
     
    -            objects = list(self._get_queryset().all()[:1])
    +            objects = self.selected_objects or self._get_queryset()
                 pk_list = [obj.pk for obj in objects]
    -            original_name = objects[0].name
    +            # Apply button not yet clicked
    +            data = {"pk": pk_list}
    +            data.update(self.rename_data)
    +            verbose_name_plural = self.model._meta.verbose_name_plural
    +
    +            if self.selected_objects_parent_name:
    +                with self.subTest("Assert parent name in HTML"):
    +                    response = self.client.post(self._get_url("bulk_rename"), data)
    +                    message = (
    +                        f"Renaming {len(objects)} {helpers.bettertitle(verbose_name_plural)} "
    +                        f"on {self.selected_objects_parent_name}"
    +                    )
    +                    self.assertBodyContains(response, message)
    +
    +            with self.subTest("Assert update successfully"):
    +                data["_apply"] = True  # Form Apply button
    +                response = self.client.post(self._get_url("bulk_rename"), data)
    +                self.assertHttpStatus(response, 302)
    +                queryset = self._get_queryset().filter(pk__in=pk_list)
    +                for instance in objects:
    +                    self.assertEqual(queryset.get(pk=instance.pk).name, f"{instance.name}X")
    +
    +            with self.subTest("Assert if no valid objects selected return with error"):
    +                for values in ([], [str(uuid.uuid4())]):
    +                    data["pk"] = values
    +                    response = self.client.post(
    +                        self._get_url("bulk_rename"), data, follow=True, headers={"HX-Request": "true"}
    +                    )
    +                    expected_message = f"No valid {verbose_name_plural} were selected."
    +                    self.assertBodyContains(response, expected_message)
    +
    +        @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
    +        def test_bulk_rename_regex_redos_protection(self):
    +            """A pattern with nested quantifiers must not stall the worker in `re.sub`.
    +
    +            The view passes the user-supplied `find` regex to `re.sub()` against each
    +            selected object's `.name`. With a name engineered to *almost* match the
    +            pattern, classic ReDoS regexes like `(a+)+$` trigger catastrophic
    +            backtracking and the request hangs indefinitely.
    +
    +            We bound the request with `signal.SIGALRM` — a protected view rejects the
    +            pattern up front and returns promptly; an unprotected view stalls inside
    +            `re.sub` and the alarm fires, failing the test loudly.
    +            """
    +            try:
    +                self._get_url("bulk_rename")
    +            except NoReverseMatch:
    +                self.skipTest(f"{self.model.__name__} does not have a bulk_rename route")
    +
    +            obj = self._get_queryset().first()
    +            if obj is None:
    +                self.fail(f"No {self.model.__name__} instances available")
    +            original_name = obj.name
    +
    +            # Engineer a name that triggers exponential backtracking on the alternation pattern below:
    +            # the trailing `X` forces the engine to fail end-anchor matching, and the wide alternation
    +            # of identical branches `(a|a|a|a|a)+` defeats common engine optimizations — the matcher
    +            # must enumerate every partition of the leading `a`s before declaring no match. Without
    +            # protection this stalls the worker; the timeout-based protection should reject promptly.
    +            redos_payload = "a" * 20 + "X"  # RouteTarget in particular has `name = CharField(max_length=21)`
    +            obj.name = redos_payload
    +            try:
    +                obj.save()
    +            except Exception:
    +                self.fail(f"{self.model.__name__} does not accept the ReDoS test payload as a name")
    +
    +            self.add_permissions(f"{self.model._meta.app_label}.change_{self.model._meta.model_name}")
     
                 data = {
    -                "pk": pk_list,
    +                "pk": [obj.pk],
                     "_apply": True,
    -                "find": "(a+)+$",  # Classic ReDoS pattern — nested quantifiers
    -                "replace": "X",
    +                "find": "(a|a|a|a|a)+$",  # Wide alternation — engine must enumerate every partition
    +                "replace": "Y",
                     "use_regex": True,
                 }
     
    -            self.add_permissions(f"{self.model._meta.app_label}.change_{self.model._meta.model_name}")
    +            timeout_seconds = 3
     
    -            # Should return 200 (form with error), not hang or 500
    -            response = self.client.post(self._get_url("bulk_rename"), data)
    +            def _alarm(_signum, _frame):
    +                self.fail(
    +                    f"BulkRenameView for {self.model.__name__} did not respond within "
    +                    f"{timeout_seconds}s; likely catastrophic backtracking on `(a+)+$` (ReDoS)."
    +                )
    +
    +            previous_handler = signal.signal(signal.SIGALRM, _alarm)
    +            try:
    +                signal.alarm(timeout_seconds)
    +                response = self.client.post(self._get_url("bulk_rename"), data)
    +            finally:
    +                signal.alarm(0)
    +                signal.signal(signal.SIGALRM, previous_handler)
    +
    +            # Should return 200 (form re-rendered with error), not hang or 500.
                 self.assertHttpStatus(response, 200)
    -            self.assertBodyContains(response, "nested quantifiers")
     
    -            # Name should be unchanged
    -            objects[0].refresh_from_db()
    -            self.assertEqual(objects[0].name, original_name)
    +            # Name must be unchanged — the malicious regex must never have been applied.
    +            obj.refresh_from_db()
    +            self.assertEqual(obj.name, redos_payload)
    +
    +            # The user-facing form error should explain the timeout.
    +            self.assertBodyContains(response, "catastrophic backtracking")
    +
    +            # Restore the original name so subsequent tests using this fixture aren't disturbed.
    +            obj.name = original_name
    +            obj.save()
     
             @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
             def test_bulk_rename_plain_string_replace(self):
    +            """POST with use_regex=False should do a plain string find/replace."""
                 try:
                     self._get_url("bulk_rename")
                 except NoReverseMatch:
                     self.skipTest(f"{self.model.__name__} does not have a bulk_rename route")
    -            """POST with use_regex=False should do a plain string find/replace."""
    -            if not self._has_redos_protection():
    -                self.skipTest("Only applies to ObjectBulkRenameViewMixin")
                 objects = list(self._get_queryset().all()[:1])
                 pk_list = [obj.pk for obj in objects]
                 original_name = objects[0].name
    @@ -2076,8 +2145,6 @@ class DeviceComponentViewTestCase(
             maxDiff = None
             bulk_add_data = None
             """Used for bulk-add (distinct from bulk-create) view testing; self.bulk_create_data will be used if unset."""
    -        selected_objects: list[ComponentModel]
    -        selected_objects_parent_name: str
     
             @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
             def test_bulk_add_component(self):
    @@ -2124,42 +2191,6 @@ def test_bulk_add_component(self):
                         pass
                 self.assertEqual(matching_count, self.bulk_create_count)
     
    -        @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
    -        def test_bulk_rename(self):
    -            self.add_permissions(f"{self.model._meta.app_label}.change_{self.model._meta.model_name}")
    -
    -            objects = self.selected_objects
    -            pk_list = [obj.pk for obj in objects]
    -            # Apply button not yet clicked
    -            data = {"pk": pk_list}
    -            data.update(self.rename_data)
    -            verbose_name_plural = self.model._meta.verbose_name_plural
    -
    -            with self.subTest("Assert device name in HTML"):
    -                response = self.client.post(self._get_url("bulk_rename"), data)
    -                message = (
    -                    f"Renaming {len(objects)} {helpers.bettertitle(verbose_name_plural)} "
    -                    f"on {self.selected_objects_parent_name}"
    -                )
    -                self.assertBodyContains(response, message)
    -
    -            with self.subTest("Assert update successfully"):
    -                data["_apply"] = True  # Form Apply button
    -                response = self.client.post(self._get_url("bulk_rename"), data)
    -                self.assertHttpStatus(response, 302)
    -                queryset = self._get_queryset().filter(pk__in=pk_list)
    -                for instance in objects:
    -                    self.assertEqual(queryset.get(pk=instance.pk).name, f"{instance.name}X")
    -
    -            with self.subTest("Assert if no valid objects selected return with error"):
    -                for values in ([], [str(uuid.uuid4())]):
    -                    data["pk"] = values
    -                    response = self.client.post(
    -                        self._get_url("bulk_rename"), data, follow=True, headers={"HX-Request": "true"}
    -                    )
    -                    expected_message = f"No valid {verbose_name_plural} were selected."
    -                    self.assertBodyContains(response, expected_message)
    -
             @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
             def test_modular_component_create_form_fields(self):
                 """Test that the modular component create form has the expected fields."""
    
  • nautobot/core/views/generic.py+37 23 modified
    @@ -1,6 +1,5 @@
     from copy import deepcopy
     import logging
    -import re
     from typing import ClassVar, Optional
     
     from django.conf import settings
    @@ -25,6 +24,7 @@
     from django.views.generic import View
     from django_filters import FilterSet
     from django_tables2 import RequestConfig, Table
    +import regex
     
     from nautobot.core.api.utils import get_serializer_for_model
     from nautobot.core.constants import MAX_PAGE_SIZE_DEFAULT
    @@ -1176,6 +1176,7 @@ class BulkRenameView(UIComponentsMixin, GetReturnURLMixin, ObjectPermissionRequi
     
         queryset: Optional[QuerySet] = None  # TODO: required, declared Optional only to avoid a breaking change
         template_name = "generic/object_bulk_rename.html"
    +    bulk_rename_regex_timeout = 1.0
     
         def __init__(self, *args, **kwargs):
             super().__init__(*args, **kwargs)
    @@ -1202,26 +1203,40 @@ def post(self, request):
             if "_preview" in request.POST or "_apply" in request.POST:
                 form = self.form(request.POST, initial={"pk": query_pks})
                 if form.is_valid():
    -                try:
    -                    with transaction.atomic():
    -                        renamed_pks = []
    -                        for obj in selected_objects:
    -                            find = form.cleaned_data["find"]
    -                            replace = form.cleaned_data["replace"]
    -                            if form.cleaned_data["use_regex"]:
    -                                try:
    -                                    obj.new_name = re.sub(find, replace, obj.name)
    -                                # Catch regex group reference errors
    -                                except re.error:
    -                                    obj.new_name = obj.name
    -                            else:
    -                                obj.new_name = obj.name.replace(find, replace)
    -                            renamed_pks.append(obj.pk)
    -
    -                        if "_apply" in request.POST:
    +                find = form.cleaned_data["find"]
    +                replace = form.cleaned_data["replace"]
    +                use_regex = form.cleaned_data["use_regex"]
    +
    +                # Phase 1: compute new_name for each object so the preview can show old → new,
    +                # surfacing any regex compile error or timeout to the form.
    +                if use_regex:
    +                    try:
    +                        pattern = regex.compile(find)
    +                    except regex.error as e:
    +                        form.add_error("find", f"Invalid regex: {e}")
    +                    else:
    +                        try:
    +                            for obj in selected_objects:
    +                                obj.new_name = pattern.sub(replace, obj.name, timeout=self.bulk_rename_regex_timeout)
    +                        except TimeoutError:
    +                            form.add_error(
    +                                "find",
    +                                f"Regex matching exceeded {self.bulk_rename_regex_timeout}s and was aborted; "
    +                                "the pattern may have catastrophic backtracking. Please simplify the expression.",
    +                            )
    +                else:
    +                    for obj in selected_objects:
    +                        obj.new_name = obj.name.replace(find, replace)
    +
    +                # Phase 2: persist the rename only if "Apply" was clicked and Phase 1 succeeded.
    +                if "_apply" in request.POST and not form.errors:
    +                    try:
    +                        with transaction.atomic():
    +                            renamed_pks = []
                                 for obj in selected_objects:
                                     obj.name = obj.new_name
                                     obj.save()
    +                                renamed_pks.append(obj.pk)
     
                                 # Enforce constrained permissions
                                 if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):
    @@ -1232,11 +1247,10 @@ def post(self, request):
                                     f"Renamed {len(selected_objects)} {self.queryset.model._meta.verbose_name_plural}",
                                 )
                                 return redirect(self.get_return_url(request))
    -
    -                except ObjectDoesNotExist:
    -                    msg = "Object update failed due to object-level permissions violation"
    -                    logger.debug(msg)
    -                    form.add_error(None, msg)
    +                    except ObjectDoesNotExist:
    +                        msg = "Object update failed due to object-level permissions violation"
    +                        logger.debug(msg)
    +                        form.add_error(None, msg)
     
             else:
                 form = self.form(initial={"pk": query_pks})
    
  • nautobot/core/views/mixins.py+18 30 modified
    @@ -1,5 +1,4 @@
     import logging
    -import re
     from typing import ClassVar, Optional, Type, Union
     
     from django.contrib import messages
    @@ -27,6 +26,7 @@
     from django.views.generic.edit import FormView
     from django_filters import FilterSet
     from drf_spectacular.utils import extend_schema
    +import regex
     from rest_framework import exceptions, mixins
     from rest_framework.decorators import action as drf_action
     from rest_framework.parsers import FormParser, MultiPartParser
    @@ -64,16 +64,6 @@
     from nautobot.extras.tables import NoteTable, ObjectChangeTable
     from nautobot.extras.utils import bulk_delete_with_bulk_change_logging, get_base_template, remove_prefix_from_cf_key
     
    -# Matches a group containing a quantifier, followed by another quantifier
    -# e.g. (a+)+, (a*)+, (a+)*, ([a-z]+){2,}, etc.
    -_NESTED_QUANTIFIER_RE = re.compile(r"\([^)]*[+*][^)]*\)[+*{]")
    -
    -
    -def is_safe_regex(pattern_str):
    -    """Check that a regex pattern does not contain nested quantifiers that could cause catastrophic backtracking."""
    -    return not _NESTED_QUANTIFIER_RE.search(pattern_str)
    -
    -
     PERMISSIONS_ACTION_MAP = {
         "list": "view",
         "retrieve": "view",
    @@ -1620,6 +1610,7 @@ class ObjectBulkRenameViewMixin(NautobotViewSetMixin):
         logger = logging.getLogger(__name__)
         queryset: QuerySet
         bulk_rename_template_name = "generic/object_bulk_rename.html"
    +    bulk_rename_regex_timeout = 1.0
     
         @classmethod
         def get_extra_actions(cls):
    @@ -1672,27 +1663,28 @@ def _bulk_rename(self, request):
                     replace = form.cleaned_data["replace"]
                     use_regex = form.cleaned_data["use_regex"]
     
    +                renamed_pks = []
                     if use_regex:
    -                    if not self._is_safe_regex(find):
    +                    try:
    +                        pattern = regex.compile(find)
    +                    except regex.error as e:
    +                        form.add_error("find", f"Invalid regex: {e}")
    +                        return self._render_form_response(request, form, selected_objects)
    +                    try:
    +                        for obj in selected_objects:
    +                            obj.new_name = pattern.sub(replace, obj.name, timeout=self.bulk_rename_regex_timeout)
    +                            renamed_pks.append(obj.pk)
    +                    except TimeoutError:
                             form.add_error(
                                 "find",
    -                            "Regex pattern contains nested quantifiers which could cause performance issues. "
    -                            "Please simplify the expression.",
    +                            f"Regex matching exceeded {self.bulk_rename_regex_timeout}s and was aborted; "
    +                            "the pattern may have catastrophic backtracking. Please simplify the expression.",
                             )
                             return self._render_form_response(request, form, selected_objects)
    -                    try:
    -                        pattern = re.compile(find)
    -                    except re.error as e:
    -                        form.add_error("find", f"Invalid regex: {e}")
    -                        return self._render_form_response(request, form, selected_objects)
    -
    -                renamed_pks = []
    -                for obj in selected_objects:
    -                    if use_regex:
    -                        obj.new_name = pattern.sub(replace, obj.name)
    -                    else:
    +                else:
    +                    for obj in selected_objects:
                             obj.new_name = obj.name.replace(find, replace)
    -                    renamed_pks.append(obj.pk)
    +                        renamed_pks.append(obj.pk)
     
                     if action == "_apply":
                         for obj in selected_objects:
    @@ -1721,10 +1713,6 @@ def _bulk_rename(self, request):
     
             return self._render_form_response(request, form, selected_objects)
     
    -    @staticmethod
    -    def _is_safe_regex(pattern_str):
    -        return is_safe_regex(pattern_str)
    -
         def _create_bulk_rename_form_class(self):
             class _Form(BulkRenameForm):
                 pk = ModelMultipleChoiceField(queryset=self.get_queryset(), widget=MultipleHiddenInput())
    
  • nautobot/dcim/views.py+5 96 modified
    @@ -2,7 +2,6 @@
     from copy import deepcopy
     from functools import partial
     import logging
    -import re
     import uuid
     
     from django.contrib import messages
    @@ -27,13 +26,12 @@
     from django.views.generic import View
     from django_tables2 import RequestConfig
     from rest_framework.decorators import action
    -from rest_framework.exceptions import MethodNotAllowed
     from rest_framework.response import Response
     
     from nautobot.cloud.tables import CloudAccountTable
     from nautobot.core.choices import ButtonActionColorChoices, ButtonColorChoices
     from nautobot.core.exceptions import AbortTransaction
    -from nautobot.core.forms import BulkRenameForm, ConfirmationForm, ImportForm, restrict_form_fields
    +from nautobot.core.forms import ConfirmationForm, ImportForm, restrict_form_fields
     from nautobot.core.models.querysets import count_related
     from nautobot.core.templatetags import helpers
     from nautobot.core.ui import object_detail
    @@ -63,6 +61,7 @@
     from nautobot.core.views.mixins import (
         GetReturnURLMixin,
         ObjectBulkDestroyViewMixin,
    +    ObjectBulkRenameViewMixin,
         ObjectBulkUpdateViewMixin,
         ObjectChangeLogViewMixin,
         ObjectDestroyViewMixin,
    @@ -2484,7 +2483,7 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
     
     
     class ModuleBayCommonViewSetMixin:
    -    """NautobotUIViewSet for ModuleBay views to handle templated create and bulk rename views."""
    +    """NautobotUIViewSet for ModuleBay views to handle templated create views."""
     
         def create(self, request, *args, **kwargs):
             if request.method == "POST":
    @@ -2578,81 +2577,13 @@ def perform_create(self, request, *args, **kwargs):
                 },
             )
     
    -    def _bulk_rename(self, request, *args, **kwargs):
    -        # TODO: This shouldn't be needed but default behavior of custom actions that don't support "GET" is broken
    -        if request.method != "POST":
    -            raise MethodNotAllowed(request.method)
    -
    -        query_pks = request.POST.getlist("pk")
    -        selected_objects = self.get_queryset().filter(pk__in=query_pks) if query_pks else None
    -
    -        # Create a new Form class from BulkRenameForm
    -        class _Form(BulkRenameForm):
    -            pk = ModelMultipleChoiceField(queryset=self.get_queryset(), widget=MultipleHiddenInput())
    -
    -        # selected_objects would return False; if no query_pks or invalid query_pks
    -        if not selected_objects:
    -            messages.warning(request, f"No valid {self.queryset.model._meta.verbose_name_plural} were selected.")
    -            return redirect(self.get_return_url(request))
    -
    -        if "_preview" in request.POST or "_apply" in request.POST:
    -            form = _Form(request.POST, initial={"pk": query_pks})
    -            if form.is_valid():
    -                try:
    -                    with transaction.atomic():
    -                        renamed_pks = []
    -                        for obj in selected_objects:
    -                            find = form.cleaned_data["find"]
    -                            replace = form.cleaned_data["replace"]
    -                            if form.cleaned_data["use_regex"]:
    -                                try:
    -                                    obj.new_name = re.sub(find, replace, obj.name)
    -                                # Catch regex group reference errors
    -                                except re.error:
    -                                    obj.new_name = obj.name
    -                            else:
    -                                obj.new_name = obj.name.replace(find, replace)
    -                            renamed_pks.append(obj.pk)
    -
    -                        if "_apply" in request.POST:
    -                            for obj in selected_objects:
    -                                obj.name = obj.new_name
    -                                obj.save()
    -
    -                            # Enforce constrained permissions
    -                            if self.get_queryset().filter(pk__in=renamed_pks).count() != len(selected_objects):
    -                                raise ObjectDoesNotExist
    -
    -                            messages.success(
    -                                request,
    -                                f"Renamed {len(selected_objects)} {self.queryset.model._meta.verbose_name_plural}",
    -                            )
    -                            return redirect(self.get_return_url(request))
    -
    -                except ObjectDoesNotExist:
    -                    msg = "Object update failed due to object-level permissions violation"
    -                    form.add_error(None, msg)
    -
    -        else:
    -            form = _Form(initial={"pk": query_pks})
    -
    -        return Response(
    -            {
    -                "template": "generic/object_bulk_rename.html",
    -                "form": form,
    -                "obj_type_plural": self.queryset.model._meta.verbose_name_plural,
    -                "selected_objects": selected_objects,
    -                "return_url": self.get_return_url(request),
    -                "parent_name": self.get_selected_objects_parents_name(selected_objects),
    -            }
    -        )
    -
     
     class ModuleBayTemplateUIViewSet(
         ModuleBayCommonViewSetMixin,
         ObjectEditViewMixin,
         ObjectDestroyViewMixin,
         ObjectBulkDestroyViewMixin,
    +    ObjectBulkRenameViewMixin,
         ObjectBulkUpdateViewMixin,
     ):
         queryset = ModuleBayTemplate.objects.all()
    @@ -2673,17 +2604,6 @@ def get_selected_objects_parents_name(self, selected_objects):
                 return parent.display
             return ""
     
    -    @action(
    -        detail=False,
    -        methods=["GET", "POST"],
    -        url_path="rename",
    -        url_name="bulk_rename",
    -        custom_view_base_action="change",
    -        custom_view_additional_permissions=["dcim.change_modulebaytemplate"],
    -    )
    -    def bulk_rename(self, request, *args, **kwargs):
    -        return self._bulk_rename(request, *args, **kwargs)
    -
     
     #
     # Platforms
    @@ -5097,7 +5017,7 @@ class DeviceBayBulkDeleteView(generic.BulkDeleteView):
     #
     
     
    -class ModuleBayUIViewSet(ModuleBayCommonViewSetMixin, NautobotUIViewSet):
    +class ModuleBayUIViewSet(ModuleBayCommonViewSetMixin, NautobotUIViewSet, ObjectBulkRenameViewMixin):
         queryset = ModuleBay.objects.all()
         filterset_class = filters.ModuleBayFilterSet
         filterset_form_class = forms.ModuleBayFilterForm
    @@ -5162,17 +5082,6 @@ def get_selected_objects_parents_name(self, selected_objects):
                 return parent.display
             return ""
     
    -    @action(
    -        detail=False,
    -        methods=["GET", "POST"],
    -        url_path="rename",
    -        url_name="bulk_rename",
    -        custom_view_base_action="change",
    -        custom_view_additional_permissions=["dcim.change_modulebay"],
    -    )
    -    def bulk_rename(self, request, *args, **kwargs):
    -        return self._bulk_rename(request, *args, **kwargs)
    -
     
     #
     # Inventory items
    
  • poetry.lock+13 8 modified
    @@ -1,4 +1,4 @@
    -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
    +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
     
     [[package]]
     name = "amqp"
    @@ -1180,7 +1180,7 @@ files = [
     
     [package.dependencies]
     Django = ">=4.2"
    -gprof2dot = ">=2017.09.19"
    +gprof2dot = ">=2017.9.19"
     sqlparse = "*"
     
     [package.extras]
    @@ -1803,6 +1803,7 @@ files = [
         {file = "invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8"},
         {file = "invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707"},
     ]
    +markers = {main = "extra == \"all\" or extra == \"napalm\""}
     
     [[package]]
     name = "isodate"
    @@ -1892,7 +1893,7 @@ files = [
     
     [package.dependencies]
     attrs = ">=22.2.0"
    -jsonschema-specifications = ">=2023.03.6"
    +jsonschema-specifications = ">=2023.3.6"
     referencing = ">=0.28.4"
     rpds-py = ">=0.25.0"
     
    @@ -1989,7 +1990,7 @@ librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""]
     mongodb = ["pymongo (==4.15.3)"]
     msgpack = ["msgpack (==1.1.2)"]
     pyro = ["pyro4 (==4.82)"]
    -qpid = ["qpid-python (==1.36.0-1)", "qpid-tools (==1.36.0-1)"]
    +qpid = ["qpid-python (==1.36.0.post1)", "qpid-tools (==1.36.0.post1)"]
     redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2,<6.5)"]
     slmq = ["softlayer_messaging (>=1.0.3)"]
     sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"]
    @@ -2010,7 +2011,7 @@ files = [
     ]
     
     [package.dependencies]
    -certifi = ">=14.05.14"
    +certifi = ">=14.5.14"
     durationpy = ">=0.7"
     python-dateutil = ">=2.5.3"
     pyyaml = ">=5.4.1"
    @@ -2273,6 +2274,7 @@ files = [
         {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
         {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
     ]
    +markers = {main = "extra == \"all\" or extra == \"napalm\""}
     
     [package.dependencies]
     mdurl = ">=0.1,<1.0"
    @@ -2423,6 +2425,7 @@ files = [
         {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
         {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
     ]
    +markers = {main = "extra == \"all\" or extra == \"napalm\""}
     
     [[package]]
     name = "mergedeep"
    @@ -3555,6 +3558,7 @@ files = [
         {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
         {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
     ]
    +markers = {main = "extra == \"all\" or extra == \"napalm\""}
     
     [package.extras]
     windows-terminal = ["colorama (>=0.4.6)"]
    @@ -4286,7 +4290,7 @@ version = "2026.4.4"
     description = "Alternative regular expression module, to replace re."
     optional = false
     python-versions = ">=3.10"
    -groups = ["linting"]
    +groups = ["main", "linting"]
     files = [
         {file = "regex-2026.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:74fa82dcc8143386c7c0392e18032009d1db715c25f4ba22d23dc2e04d02a20f"},
         {file = "regex-2026.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a85b620a388d6c9caa12189233109e236b3da3deffe4ff11b84ae84e218a274f"},
    @@ -4471,6 +4475,7 @@ files = [
         {file = "rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952"},
         {file = "rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9"},
     ]
    +markers = {main = "extra == \"all\" or extra == \"napalm\""}
     
     [package.dependencies]
     markdown-it-py = ">=2.2.0"
    @@ -5236,7 +5241,7 @@ jinja2 = "*"
     tomli = {version = "*", markers = "python_version < \"3.11\""}
     
     [package.extras]
    -dev = ["furo (>=2024.05.06)", "nox", "packaging", "sphinx (>=5)", "twisted"]
    +dev = ["furo (>=2024.5.6)", "nox", "packaging", "sphinx (>=5)", "twisted"]
     
     [[package]]
     name = "tqdm"
    @@ -5666,4 +5671,4 @@ sso = ["social-auth-core"]
     [metadata]
     lock-version = "2.1"
     python-versions = ">=3.10,<3.15"
    -content-hash = "0e3352633566d6c4767af5827d10eab60b653b5652c29317ce9bd33aaa6da0e3"
    +content-hash = "c81fe28f1a126643f7bd6d0a713cb97f52500a9860b82b4cb3cb3bd55251df8f"
    
  • pyproject.toml+2 0 modified
    @@ -113,6 +113,8 @@ dependencies = [
         "pyuwsgi (>=2.0.30,<2.1)",
         # YAML parsing and rendering
         "PyYAML (>=6.0.3,<6.1)",
    +    # Enhanced version of Python's built-in `re` module
    +    "regex (>=2026.4.4)",
         # Social authentication/registration with support for many auth providers
         "social-auth-app-django (>=5.7.0,<5.8)",
         # Rendering of SVG images (for rack elevations, etc.)
    
c2b766966d81

Merge commit from fork

https://github.com/nautobot/nautobotGlenn MatthewsMay 7, 2026via ghsa
7 files changed · +238 88
  • changes/GHSA-qrpw-gjvh-x5gm.dependencies+1 0 added
    @@ -0,0 +1 @@
    +Added `regex>=2026.4.4` as a dependency. (Previously it was a development-only dependency.)
    
  • changes/GHSA-qrpw-gjvh-x5gm.security+1 0 added
    @@ -0,0 +1 @@
    +Added a timeout to `bulk-rename` views when doing regular-expression-based bulk renames to protect against denial-of-service (REDoS) due to an overly-complex or maliciously crafted regular expression provided by the user (GHSA-qrpw-gjvh-x5gm).
    
  • nautobot/core/testing/views.py+148 37 modified
    @@ -1,6 +1,7 @@
     import contextlib
     import inspect
     import re
    +import signal
     from typing import Optional, Sequence
     from unittest import mock, skipIf
     import uuid
    @@ -26,7 +27,6 @@
     from nautobot.core.templatetags import helpers
     from nautobot.core.testing import mixins, utils
     from nautobot.core.utils import lookup
    -from nautobot.dcim.models.device_components import ComponentModel
     from nautobot.extras import choices as extras_choices, models as extras_models, querysets as extras_querysets
     from nautobot.extras.forms import CustomFieldModelFormMixin, RelationshipModelFormMixin
     from nautobot.extras.models import CustomFieldModel, RelationshipModel
    @@ -1479,6 +1479,9 @@ class BulkRenameObjectsViewTestCase(ModelViewTestCase):
                 "replace": "\\1X",  # Append an X to the original value
                 "use_regex": True,
             }
    +        # Generally optional, used in legacy `test_bulk_rename` test for device-component models only.
    +        selected_objects: Optional[list[Model]] = None
    +        selected_objects_parent_name: Optional[str] = None
     
             def test_bulk_rename_objects_without_permission(self):
                 pk_list = list(self._get_queryset().values_list("pk", flat=True)[:3])
    @@ -1551,6 +1554,150 @@ def test_bulk_rename_objects_with_constrained_permission(self):
                     expected_name = getattr(objects[i], "name") + "X"
                     self.assertEqual(name, expected_name)
     
    +        @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
    +        def test_bulk_rename(self):
    +            """Legacy test, originally specific to DeviceComponentViewTestCase."""
    +            try:
    +                self._get_url("bulk_rename")
    +            except NoReverseMatch:
    +                self.skipTest(f"{self.model.__name__} does not have a bulk_rename route")
    +            self.add_permissions(f"{self.model._meta.app_label}.change_{self.model._meta.model_name}")
    +
    +            objects = self.selected_objects or self._get_queryset()
    +            pk_list = [obj.pk for obj in objects]
    +            # Apply button not yet clicked
    +            data = {"pk": pk_list}
    +            data.update(self.rename_data)
    +            verbose_name_plural = self.model._meta.verbose_name_plural
    +
    +            if self.selected_objects_parent_name:
    +                with self.subTest("Assert parent name in HTML"):
    +                    response = self.client.post(self._get_url("bulk_rename"), data)
    +                    message = (
    +                        f"Renaming {len(objects)} {helpers.bettertitle(verbose_name_plural)} "
    +                        f"on {self.selected_objects_parent_name}"
    +                    )
    +                    self.assertBodyContains(response, message)
    +
    +            with self.subTest("Assert update successfully"):
    +                data["_apply"] = True  # Form Apply button
    +                response = self.client.post(self._get_url("bulk_rename"), data)
    +                self.assertHttpStatus(response, 302)
    +                queryset = self._get_queryset().filter(pk__in=pk_list)
    +                for instance in objects:
    +                    self.assertEqual(queryset.get(pk=instance.pk).name, f"{instance.name}X")
    +
    +            with self.subTest("Assert if no valid objects selected return with error"):
    +                for values in ([], [str(uuid.uuid4())]):
    +                    data["pk"] = values
    +                    response = self.client.post(
    +                        self._get_url("bulk_rename"), data, follow=True, headers={"HX-Request": "true"}
    +                    )
    +                    expected_message = f"No valid {verbose_name_plural} were selected."
    +                    self.assertBodyContains(response, expected_message)
    +
    +        @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
    +        def test_bulk_rename_regex_redos_protection(self):
    +            """A pattern with nested quantifiers must not stall the worker in `re.sub`.
    +
    +            The view passes the user-supplied `find` regex to `re.sub()` against each
    +            selected object's `.name`. With a name engineered to *almost* match the
    +            pattern, classic ReDoS regexes like `(a+)+$` trigger catastrophic
    +            backtracking and the request hangs indefinitely.
    +
    +            We bound the request with `signal.SIGALRM` — a protected view rejects the
    +            pattern up front and returns promptly; an unprotected view stalls inside
    +            `re.sub` and the alarm fires, failing the test loudly.
    +            """
    +            try:
    +                self._get_url("bulk_rename")
    +            except NoReverseMatch:
    +                self.skipTest(f"{self.model.__name__} does not have a bulk_rename route")
    +
    +            obj = self._get_queryset().first()
    +            if obj is None:
    +                self.fail(f"No {self.model.__name__} instances available")
    +            original_name = obj.name
    +
    +            # Engineer a name that triggers exponential backtracking on the alternation pattern below:
    +            # the trailing `X` forces the engine to fail end-anchor matching, and the wide alternation
    +            # of identical branches `(a|a|a|a|a)+` defeats common engine optimizations — the matcher
    +            # must enumerate every partition of the leading `a`s before declaring no match. Without
    +            # protection this stalls the worker; the timeout-based protection should reject promptly.
    +            redos_payload = "a" * 20 + "X"  # RouteTarget in particular has `name = CharField(max_length=21)`
    +            obj.name = redos_payload
    +            try:
    +                obj.save()
    +            except Exception:
    +                self.fail(f"{self.model.__name__} does not accept the ReDoS test payload as a name")
    +
    +            self.add_permissions(f"{self.model._meta.app_label}.change_{self.model._meta.model_name}")
    +
    +            data = {
    +                "pk": [obj.pk],
    +                "_apply": True,
    +                "find": "(a|a|a|a|a)+$",  # Wide alternation — engine must enumerate every partition
    +                "replace": "Y",
    +                "use_regex": True,
    +            }
    +
    +            timeout_seconds = 3
    +
    +            def _alarm(_signum, _frame):
    +                self.fail(
    +                    f"BulkRenameView for {self.model.__name__} did not respond within "
    +                    f"{timeout_seconds}s; likely catastrophic backtracking on `(a+)+$` (ReDoS)."
    +                )
    +
    +            previous_handler = signal.signal(signal.SIGALRM, _alarm)
    +            try:
    +                signal.alarm(timeout_seconds)
    +                response = self.client.post(self._get_url("bulk_rename"), data)
    +            finally:
    +                signal.alarm(0)
    +                signal.signal(signal.SIGALRM, previous_handler)
    +
    +            # Should return 200 (form re-rendered with error), not hang or 500.
    +            self.assertHttpStatus(response, 200)
    +
    +            # Name must be unchanged — the malicious regex must never have been applied.
    +            obj.refresh_from_db()
    +            self.assertEqual(obj.name, redos_payload)
    +
    +            # The user-facing form error should explain the timeout.
    +            self.assertBodyContains(response, "catastrophic backtracking")
    +
    +            # Restore the original name so subsequent tests using this fixture aren't disturbed.
    +            obj.name = original_name
    +            obj.save()
    +
    +        @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
    +        def test_bulk_rename_plain_string_replace(self):
    +            """POST with use_regex=False should do a plain string find/replace."""
    +            try:
    +                self._get_url("bulk_rename")
    +            except NoReverseMatch:
    +                self.skipTest(f"{self.model.__name__} does not have a bulk_rename route")
    +            objects = list(self._get_queryset().all()[:1])
    +            pk_list = [obj.pk for obj in objects]
    +            original_name = objects[0].name
    +            self.add_permissions(f"{self.model._meta.app_label}.change_{self.model._meta.model_name}")
    +            # Use the same regex-based rename_data pattern but with use_regex=False and a literal find
    +            # that matches part of the name. Use first char as find target for broad compatibility.
    +            first_char = original_name[0]
    +            data = {
    +                "pk": pk_list,
    +                "_preview": True,
    +                "find": first_char,
    +                "replace": first_char,
    +                "use_regex": False,
    +            }
    +            response = self.client.post(self._get_url("bulk_rename"), data)
    +            self.assertHttpStatus(response, 200)
    +            # Preview should show the name unchanged (same find/replace)
    +            objects[0].refresh_from_db()
    +            self.assertEqual(objects[0].name, original_name)
    +
         class PrimaryObjectViewTestCase(
             GetObjectViewTestCase,
             GetObjectChangelogViewTestCase,
    @@ -1617,8 +1764,6 @@ class DeviceComponentViewTestCase(
             maxDiff = None
             bulk_add_data = None
             """Used for bulk-add (distinct from bulk-create) view testing; self.bulk_create_data will be used if unset."""
    -        selected_objects: list[ComponentModel]
    -        selected_objects_parent_name: str
     
             @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
             def test_bulk_add_component(self):
    @@ -1664,37 +1809,3 @@ def test_bulk_add_component(self):
                     except AssertionError:
                         pass
                 self.assertEqual(matching_count, self.bulk_create_count)
    -
    -        @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
    -        def test_bulk_rename(self):
    -            self.add_permissions(f"{self.model._meta.app_label}.change_{self.model._meta.model_name}")
    -
    -            objects = self.selected_objects
    -            pk_list = [obj.pk for obj in objects]
    -            # Apply button not yet clicked
    -            data = {"pk": pk_list}
    -            data.update(self.rename_data)
    -            verbose_name_plural = self.model._meta.verbose_name_plural
    -
    -            with self.subTest("Assert device name in HTML"):
    -                response = self.client.post(self._get_url("bulk_rename"), data)
    -                message = (
    -                    f"Renaming {len(objects)} {helpers.bettertitle(verbose_name_plural)} "
    -                    f"on {self.selected_objects_parent_name}"
    -                )
    -                self.assertBodyContains(response, message)
    -
    -            with self.subTest("Assert update successfully"):
    -                data["_apply"] = True  # Form Apply button
    -                response = self.client.post(self._get_url("bulk_rename"), data)
    -                self.assertHttpStatus(response, 302)
    -                queryset = self._get_queryset().filter(pk__in=pk_list)
    -                for instance in objects:
    -                    self.assertEqual(queryset.get(pk=instance.pk).name, f"{instance.name}X")
    -
    -            with self.subTest("Assert if no valid objects selected return with error"):
    -                for values in ([], [str(uuid.uuid4())]):
    -                    data["pk"] = values
    -                    response = self.client.post(self._get_url("bulk_rename"), data, follow=True)
    -                    expected_message = f"No valid {verbose_name_plural} were selected."
    -                    self.assertBodyContains(response, expected_message)
    
  • nautobot/core/views/generic.py+37 23 modified
    @@ -1,6 +1,5 @@
     from copy import deepcopy
     import logging
    -import re
     from typing import ClassVar, Optional
     
     from django.conf import settings
    @@ -24,6 +23,7 @@
     from django.views.generic import View
     from django_filters import FilterSet
     from django_tables2 import RequestConfig, Table
    +import regex
     
     from nautobot.core.api.utils import get_serializer_for_model
     from nautobot.core.constants import MAX_PAGE_SIZE_DEFAULT
    @@ -1130,6 +1130,7 @@ class BulkRenameView(UIComponentsMixin, GetReturnURLMixin, ObjectPermissionRequi
     
         queryset: Optional[QuerySet] = None  # TODO: required, declared Optional only to avoid a breaking change
         template_name = "generic/object_bulk_rename.html"
    +    bulk_rename_regex_timeout = 1.0
     
         def __init__(self, *args, **kwargs):
             super().__init__(*args, **kwargs)
    @@ -1156,26 +1157,40 @@ def post(self, request):
             if "_preview" in request.POST or "_apply" in request.POST:
                 form = self.form(request.POST, initial={"pk": query_pks})
                 if form.is_valid():
    -                try:
    -                    with transaction.atomic():
    -                        renamed_pks = []
    -                        for obj in selected_objects:
    -                            find = form.cleaned_data["find"]
    -                            replace = form.cleaned_data["replace"]
    -                            if form.cleaned_data["use_regex"]:
    -                                try:
    -                                    obj.new_name = re.sub(find, replace, obj.name)
    -                                # Catch regex group reference errors
    -                                except re.error:
    -                                    obj.new_name = obj.name
    -                            else:
    -                                obj.new_name = obj.name.replace(find, replace)
    -                            renamed_pks.append(obj.pk)
    -
    -                        if "_apply" in request.POST:
    +                find = form.cleaned_data["find"]
    +                replace = form.cleaned_data["replace"]
    +                use_regex = form.cleaned_data["use_regex"]
    +
    +                # Phase 1: compute new_name for each object so the preview can show old → new,
    +                # surfacing any regex compile error or timeout to the form.
    +                if use_regex:
    +                    try:
    +                        pattern = regex.compile(find)
    +                    except regex.error as e:
    +                        form.add_error("find", f"Invalid regex: {e}")
    +                    else:
    +                        try:
    +                            for obj in selected_objects:
    +                                obj.new_name = pattern.sub(replace, obj.name, timeout=self.bulk_rename_regex_timeout)
    +                        except TimeoutError:
    +                            form.add_error(
    +                                "find",
    +                                f"Regex matching exceeded {self.bulk_rename_regex_timeout}s and was aborted; "
    +                                "the pattern may have catastrophic backtracking. Please simplify the expression.",
    +                            )
    +                else:
    +                    for obj in selected_objects:
    +                        obj.new_name = obj.name.replace(find, replace)
    +
    +                # Phase 2: persist the rename only if "Apply" was clicked and Phase 1 succeeded.
    +                if "_apply" in request.POST and not form.errors:
    +                    try:
    +                        with transaction.atomic():
    +                            renamed_pks = []
                                 for obj in selected_objects:
                                     obj.name = obj.new_name
                                     obj.save()
    +                                renamed_pks.append(obj.pk)
     
                                 # Enforce constrained permissions
                                 if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):
    @@ -1186,11 +1201,10 @@ def post(self, request):
                                     f"Renamed {len(selected_objects)} {self.queryset.model._meta.verbose_name_plural}",
                                 )
                                 return redirect(self.get_return_url(request))
    -
    -                except ObjectDoesNotExist:
    -                    msg = "Object update failed due to object-level permissions violation"
    -                    logger.debug(msg)
    -                    form.add_error(None, msg)
    +                    except ObjectDoesNotExist:
    +                        msg = "Object update failed due to object-level permissions violation"
    +                        logger.debug(msg)
    +                        form.add_error(None, msg)
     
             else:
                 form = self.form(initial={"pk": query_pks})
    
  • nautobot/dcim/views.py+36 20 modified
    @@ -2,7 +2,6 @@
     from copy import deepcopy
     from functools import partial
     import logging
    -import re
     import uuid
     
     from django.contrib import messages
    @@ -25,6 +24,7 @@
     from django.utils.http import url_has_allowed_host_and_scheme, urlencode
     from django.views.generic import View
     from django_tables2 import RequestConfig
    +import regex
     from rest_framework.decorators import action
     from rest_framework.exceptions import MethodNotAllowed
     from rest_framework.response import Response
    @@ -1966,6 +1966,8 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
     class ModuleBayCommonViewSetMixin:
         """NautobotUIViewSet for ModuleBay views to handle templated create and bulk rename views."""
     
    +    bulk_rename_regex_timeout = 1.0
    +
         def create(self, request, *args, **kwargs):
             if request.method == "POST":
                 return self.perform_create(request, *args, **kwargs)
    @@ -2078,26 +2080,40 @@ class _Form(BulkRenameForm):
             if "_preview" in request.POST or "_apply" in request.POST:
                 form = _Form(request.POST, initial={"pk": query_pks})
                 if form.is_valid():
    -                try:
    -                    with transaction.atomic():
    -                        renamed_pks = []
    -                        for obj in selected_objects:
    -                            find = form.cleaned_data["find"]
    -                            replace = form.cleaned_data["replace"]
    -                            if form.cleaned_data["use_regex"]:
    -                                try:
    -                                    obj.new_name = re.sub(find, replace, obj.name)
    -                                # Catch regex group reference errors
    -                                except re.error:
    -                                    obj.new_name = obj.name
    -                            else:
    -                                obj.new_name = obj.name.replace(find, replace)
    -                            renamed_pks.append(obj.pk)
    +                find = form.cleaned_data["find"]
    +                replace = form.cleaned_data["replace"]
    +                use_regex = form.cleaned_data["use_regex"]
     
    -                        if "_apply" in request.POST:
    +                # Phase 1: compute new_name for each object so the preview can show old -> new,
    +                # surfacing any regex compile error or timeout to the form.
    +                if use_regex:
    +                    try:
    +                        pattern = regex.compile(find)
    +                    except regex.error as e:
    +                        form.add_error("find", f"Invalid regex: {e}")
    +                    else:
    +                        try:
    +                            for obj in selected_objects:
    +                                obj.new_name = pattern.sub(replace, obj.name, timeout=self.bulk_rename_regex_timeout)
    +                        except TimeoutError:
    +                            form.add_error(
    +                                "find",
    +                                f"Regex matching exceeded {self.bulk_rename_regex_timeout}s and was aborted; "
    +                                "the pattern may have catastrophic backtracking. Please simplify the expression.",
    +                            )
    +                else:
    +                    for obj in selected_objects:
    +                        obj.new_name = obj.name.replace(find, replace)
    +
    +                # Phase 2: persist the rename only if "Apply" was clicked and Phase 1 succeeded
    +                if "_apply" in request.POST and not form.errors:
    +                    try:
    +                        with transaction.atomic():
    +                            renamed_pks = []
                                 for obj in selected_objects:
                                     obj.name = obj.new_name
                                     obj.save()
    +                                renamed_pks.append(obj.pk)
     
                                 # Enforce constrained permissions
                                 if self.get_queryset().filter(pk__in=renamed_pks).count() != len(selected_objects):
    @@ -2109,9 +2125,9 @@ class _Form(BulkRenameForm):
                                 )
                                 return redirect(self.get_return_url(request))
     
    -                except ObjectDoesNotExist:
    -                    msg = "Object update failed due to object-level permissions violation"
    -                    form.add_error(None, msg)
    +                    except ObjectDoesNotExist:
    +                        msg = "Object update failed due to object-level permissions violation"
    +                        form.add_error(None, msg)
     
             else:
                 form = _Form(initial={"pk": query_pks})
    
  • poetry.lock+13 8 modified
    @@ -1,4 +1,4 @@
    -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
    +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
     
     [[package]]
     name = "amqp"
    @@ -1177,7 +1177,7 @@ files = [
     
     [package.dependencies]
     Django = ">=4.2"
    -gprof2dot = ">=2017.09.19"
    +gprof2dot = ">=2017.9.19"
     sqlparse = "*"
     
     [package.extras]
    @@ -1871,6 +1871,7 @@ files = [
         {file = "invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8"},
         {file = "invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707"},
     ]
    +markers = {main = "extra == \"all\" or extra == \"napalm\""}
     
     [[package]]
     name = "isodate"
    @@ -1961,7 +1962,7 @@ files = [
     
     [package.dependencies]
     attrs = ">=22.2.0"
    -jsonschema-specifications = ">=2023.03.6"
    +jsonschema-specifications = ">=2023.3.6"
     referencing = ">=0.28.4"
     rpds-py = ">=0.25.0"
     
    @@ -2056,7 +2057,7 @@ librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""]
     mongodb = ["pymongo (==4.15.3)"]
     msgpack = ["msgpack (==1.1.2)"]
     pyro = ["pyro4 (==4.82)"]
    -qpid = ["qpid-python (==1.36.0-1)", "qpid-tools (==1.36.0-1)"]
    +qpid = ["qpid-python (==1.36.0.post1)", "qpid-tools (==1.36.0.post1)"]
     redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2,<6.5)"]
     slmq = ["softlayer_messaging (>=1.0.3)"]
     sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"]
    @@ -2077,7 +2078,7 @@ files = [
     ]
     
     [package.dependencies]
    -certifi = ">=14.05.14"
    +certifi = ">=14.5.14"
     durationpy = ">=0.7"
     google-auth = ">=1.0.1"
     oauthlib = ">=3.2.2"
    @@ -2341,6 +2342,7 @@ files = [
         {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
         {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
     ]
    +markers = {main = "extra == \"all\" or extra == \"napalm\""}
     
     [package.dependencies]
     mdurl = ">=0.1,<1.0"
    @@ -2491,6 +2493,7 @@ files = [
         {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
         {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
     ]
    +markers = {main = "extra == \"all\" or extra == \"napalm\""}
     
     [[package]]
     name = "mergedeep"
    @@ -3432,6 +3435,7 @@ files = [
         {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
         {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
     ]
    +markers = {main = "extra == \"all\" or extra == \"napalm\""}
     
     [package.extras]
     windows-terminal = ["colorama (>=0.4.6)"]
    @@ -4149,7 +4153,7 @@ version = "2026.4.4"
     description = "Alternative regular expression module, to replace re."
     optional = false
     python-versions = ">=3.10"
    -groups = ["linting"]
    +groups = ["main", "linting"]
     files = [
         {file = "regex-2026.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:74fa82dcc8143386c7c0392e18032009d1db715c25f4ba22d23dc2e04d02a20f"},
         {file = "regex-2026.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a85b620a388d6c9caa12189233109e236b3da3deffe4ff11b84ae84e218a274f"},
    @@ -4334,6 +4338,7 @@ files = [
         {file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"},
         {file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"},
     ]
    +markers = {main = "extra == \"all\" or extra == \"napalm\""}
     
     [package.dependencies]
     markdown-it-py = ">=2.2.0"
    @@ -5144,7 +5149,7 @@ jinja2 = "*"
     tomli = {version = "*", markers = "python_version < \"3.11\""}
     
     [package.extras]
    -dev = ["furo (>=2024.05.06)", "nox", "packaging", "sphinx (>=5)", "twisted"]
    +dev = ["furo (>=2024.5.6)", "nox", "packaging", "sphinx (>=5)", "twisted"]
     
     [[package]]
     name = "tqdm"
    @@ -5558,4 +5563,4 @@ sso = ["social-auth-core"]
     [metadata]
     lock-version = "2.1"
     python-versions = ">=3.10,<3.13"
    -content-hash = "1d42f4374a8cb7d2ec548015f4c293783aafd63f1cbead986971cf9209b4c399"
    +content-hash = "5627560364a4634f7254aab96b8cc1960bec77eaa3f82fa32c0f4544ac424caa"
    
  • pyproject.toml+2 0 modified
    @@ -137,6 +137,8 @@ python-slugify = "~8.0.4"
     pyuwsgi = "~2.0.30"
     # YAML parsing and rendering
     PyYAML = "~6.0.3"
    +# Enhanced version of Python's built-in `re` module
    +regex = ">=2026.4.4"
     # Social authentication core
     # Note that social-auth-app-django (below) is *not* an optional dependency, and it requires social-auth-core in turn.
     # The only part of this dependency that is actually optional is in fact social-auth-core's "saml" extra.
    

Vulnerability mechanics

AI mechanics synthesis has not run for this CVE yet.

References

6

News mentions

0

No linked articles in our index yet.