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
1Patches
25a30d0916953Merge commit from fork
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.)
c2b766966d81Merge commit from fork
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- github.com/advisories/GHSA-qrpw-gjvh-x5gmghsaADVISORY
- github.com/nautobot/nautobot/commit/5a30d0916953afbeedd24a784709e762cc3879cdghsa
- github.com/nautobot/nautobot/commit/c2b766966d814a7141f62c7bc90c85fefb7892eeghsa
- github.com/nautobot/nautobot/releases/tag/v2.4.33ghsa
- github.com/nautobot/nautobot/releases/tag/v3.1.2ghsa
- github.com/nautobot/nautobot/security/advisories/GHSA-qrpw-gjvh-x5gmghsa
News mentions
0No linked articles in our index yet.