VYPR
High severityNVD Advisory· Published Nov 22, 2023· Updated Aug 2, 2024

nautobot has XSS potential in custom links, job buttons, and computed fields

CVE-2023-48705

Description

Nautobot is a Network Source of Truth and Network Automation Platform built as a web application All users of Nautobot versions earlier than 1.6.6 or 2.0.5 are potentially affected by a cross-site scripting vulnerability. Due to incorrect usage of Django's mark_safe() API when rendering certain types of user-authored content; including custom links, job buttons, and computed fields; it is possible that users with permission to create or edit these types of content could craft a malicious payload (such as JavaScript code) that would be executed when rendering pages containing this content. The maintainers have fixed the incorrect uses of mark_safe() (generally by replacing them with appropriate use of format_html() instead) to prevent such malicious data from being executed. Users on Nautobot 1.6.x LTM should upgrade to v1.6.6 and users on Nautobot 2.0.x should upgrade to v2.0.5. Appropriate object permissions can and should be applied to restrict which users are permitted to create or edit the aforementioned types of user-authored content. Other than that, there is no direct workaround available.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Nautobot versions before 1.6.6 and 2.0.5 are vulnerable to stored XSS due to misuse of Django's mark_safe() API in custom links, job buttons, and computed fields.

Vulnerability

Nautobot, a Network Source of Truth and Network Automation Platform, contains a cross-site scripting (XSS) vulnerability in versions prior to 1.6.6 and 2.0.5. The root cause is the incorrect usage of Django's mark_safe() API when rendering user-authored content such as custom links, job buttons, and computed fields. Instead of using mark_safe() which marks a string as safe without escaping, the code should have used format_html() which properly escapes HTML and prevents injection [1][2][4].

Exploitation

An authenticated attacker with permission to create or edit custom links, job buttons, or computed fields can craft a malicious payload containing JavaScript code. When other users access pages that render this content, the injected script executes in their browser context. The vulnerability does not require any special network position; it is a stored XSS triggered through normal page rendering [4].

Impact

Successful exploitation allows an attacker to execute arbitrary JavaScript in the context of a victim's session. This can lead to data theft, session hijacking, or actions performed on behalf of the victim. The issue is mitigated by restricting object permissions for who can create or edit the affected content types [4].

Mitigation

The maintainers fixed the issue by replacing all unsafe uses of mark_safe() with format_html() in commit 362850f [3]. Users on Nautobot 1.6.x LTM should upgrade to v1.6.6, and users on Nautobot 2.0.x should upgrade to v2.0.5. No direct workaround is available aside from applying strict object permissions [4].

AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
nautobotPyPI
< 1.6.61.6.6
nautobotPyPI
>= 2.0.0, < 2.0.52.0.5

Affected products

2

Patches

2
362850f5a946

[1.6] Fix unsafe `mark_safe` use in various features (#4833)

https://github.com/nautobot/nautobotGlenn MatthewsNov 21, 2023via ghsa
32 files changed · +441 163
  • changes/4833.changed+1 0 added
    @@ -0,0 +1 @@
    +Changed the `render_jinja2()` API to no longer automatically call `mark_safe()` on the output.
    
  • changes/4833.housekeeping+1 0 added
    @@ -0,0 +1 @@
    +Added `ruff` to invoke tasks and CI.
    
  • changes/4833.security+1 0 added
    @@ -0,0 +1 @@
    +Fixed cross-site-scripting (XSS) potential with maliciously crafted Custom Links, Computed Fields, and Job Buttons (GHSA-cf9f-wmhp-v4pr).
    
  • .github/workflows/ci_pullrequest.yml+13 0 modified
    @@ -31,6 +31,17 @@ jobs:
             uses: "networktocode/gh-action-setup-poetry-environment@v2"
           - name: "Linting: flake8"
             run: "poetry run invoke flake8"
    +  ruff:
    +    runs-on: "ubuntu-20.04"
    +    env:
    +      INVOKE_NAUTOBOT_LOCAL: "True"
    +    steps:
    +      - name: "Check out repository code"
    +        uses: "actions/checkout@v2"
    +      - name: "Setup environment"
    +        uses: "networktocode/gh-action-setup-poetry-environment@v2"
    +      - name: "Linting: ruff"
    +        run: "poetry run invoke ruff --output-format github"
       markdownlint:
         runs-on: "ubuntu-20.04"
         env:
    @@ -83,6 +94,7 @@ jobs:
           - "black"
           - "flake8"
           - "markdownlint"
    +      - "ruff"
           - "hadolint"
       check-schema:
         runs-on: "ubuntu-20.04"
    @@ -113,6 +125,7 @@ jobs:
           - "black"
           - "flake8"
           - "markdownlint"
    +      - "ruff"
           - "hadolint"
     
       pylint:
    
  • nautobot/core/settings.py+1 1 modified
    @@ -619,7 +619,7 @@
         ],
         "NETWORK_DRIVERS": [
             {},
    -        mark_safe(
    +        mark_safe(  # noqa: S308
                 "Extend or override default Platform.network_driver translations provided by "
                 '<a href="https://netutils.readthedocs.io/en/latest/user/lib_use_cases_lib_mapper/">netutils</a>. '
                 "Enter a dictionary in JSON format, for example:\n"
    
  • nautobot/core/views/generic.py+6 7 modified
    @@ -16,9 +16,8 @@
     from django.http import HttpResponse
     from django.shortcuts import get_object_or_404, redirect, render
     from django.urls import NoReverseMatch, reverse
    -from django.utils.html import escape
    +from django.utils.html import format_html
     from django.utils.http import is_safe_url
    -from django.utils.safestring import mark_safe
     from django.views.generic import View
     from django_tables2 import RequestConfig
     
    @@ -249,7 +248,7 @@ def get(self, request):
                 if not filterset.is_valid():
                     messages.error(
                         request,
    -                    mark_safe(f"Invalid filters were specified: {filterset.errors}"),
    +                    format_html("Invalid filters were specified: {}", filterset.errors),
                     )
                     self.queryset = self.queryset.none()
     
    @@ -461,10 +460,10 @@ def post(self, request, *args, **kwargs):
                     msg = f"{verb} {self.queryset.model._meta.verbose_name}"
                     logger.info(f"{msg} {obj} (PK: {obj.pk})")
                     if hasattr(obj, "get_absolute_url"):
    -                    msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
    +                    msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
                     else:
    -                    msg = f"{msg} {escape(obj)}"
    -                messages.success(request, mark_safe(msg))
    +                    msg = format_html("{} {}", msg, obj)
    +                messages.success(request, msg)
     
                     if "_addanother" in request.POST:
                         # If the object has clone_fields, pre-populate a new instance of the form
    @@ -794,7 +793,7 @@ def post(self, request):
                     logger.info(f"Import object {obj} (PK: {obj.pk})")
                     messages.success(
                         request,
    -                    mark_safe(f'Imported object: <a href="{obj.get_absolute_url()}">{obj}</a>'),
    +                    format_html('Imported object: <a href="{}">{}</a>', obj.get_absolute_url(), obj),
                     )
     
                     if "_addanother" in request.POST:
    
  • nautobot/core/views/mixins.py+5 6 modified
    @@ -15,8 +15,7 @@
     from django.shortcuts import get_object_or_404, redirect
     from django.template.loader import select_template, TemplateDoesNotExist
     from django.utils.http import is_safe_url
    -from django.utils.html import escape
    -from django.utils.safestring import mark_safe
    +from django.utils.html import format_html
     from django.views.generic.edit import FormView
     
     from rest_framework import mixins, exceptions
    @@ -462,7 +461,7 @@ def filter_queryset(self, queryset):
                 if not self.filterset.is_valid():
                     messages.error(
                         self.request,
    -                    mark_safe(f"Invalid filters were specified: {self.filterset.errors}"),
    +                    format_html("Invalid filters were specified: {}", self.filterset.errors),
                     )
                     queryset = queryset.none()
             return queryset
    @@ -624,10 +623,10 @@ def _process_create_or_update_form(self, form):
                 msg = f'{"Created" if object_created else "Modified"} {queryset.model._meta.verbose_name}'
                 self.logger.info(f"{msg} {obj} (PK: {obj.pk})")
                 if hasattr(obj, "get_absolute_url"):
    -                msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
    +                msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
                 else:
    -                msg = f"{msg} { escape(obj)}"
    -            messages.success(request, mark_safe(msg))
    +                msg = format_html("{} {}", msg, obj)
    +            messages.success(request, msg)
                 if "_addanother" in request.POST:
                     # If the object has clone_fields, pre-populate a new instance of the form
                     if hasattr(obj, "clone_fields"):
    
  • nautobot/dcim/forms.py+5 5 modified
    @@ -381,7 +381,7 @@ class Meta:
             model = Site
             fields = Site.csv_headers
             help_texts = {
    -            "time_zone": mark_safe(
    +            "time_zone": mark_safe(  # noqa: S308
                     'Time zone (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">available options</a>)'
                 )
             }
    @@ -447,7 +447,7 @@ class LocationTypeCSVForm(CustomFieldModelCSVForm):
             feature="locations",
             required=False,
             choices_as_strings=True,
    -        help_text=mark_safe(
    +        help_text=mark_safe(  # noqa: S308
                 "The object types to which this status applies. Multiple values "
                 "must be comma-separated and wrapped in double quotes. (e.g. "
                 '<code>"dcim.device,dcim.rack"</code>)'
    @@ -636,7 +636,7 @@ class Meta:
             model = RackRole
             fields = RackRole.csv_headers
             help_texts = {
    -            "color": mark_safe("RGB color in hexadecimal (e.g. <code>00ff00</code>)"),
    +            "color": mark_safe("RGB color in hexadecimal (e.g. <code>00ff00</code>)"),  # noqa: S308
             }
     
     
    @@ -1768,7 +1768,7 @@ class Meta:
             model = DeviceRole
             fields = DeviceRole.csv_headers
             help_texts = {
    -            "color": mark_safe("RGB color in hexadecimal (e.g. <code>00ff00</code>)"),
    +            "color": mark_safe("RGB color in hexadecimal (e.g. <code>00ff00</code>)"),  # noqa: S308
             }
     
     
    @@ -3855,7 +3855,7 @@ class Meta:
                 "length_unit",
             ]
             help_texts = {
    -            "color": mark_safe("RGB color in hexadecimal (e.g. <code>00ff00</code>)"),
    +            "color": mark_safe("RGB color in hexadecimal (e.g. <code>00ff00</code>)"),  # noqa: S308
                 "status": "Connection status",
             }
     
    
  • nautobot/dcim/models/devices.py+7 5 modified
    @@ -8,9 +8,9 @@
     from django.core.validators import MaxValueValidator, MinValueValidator
     from django.db import models
     from django.db.models import F, ProtectedError, Q
    -from django.utils.functional import cached_property
     from django.urls import reverse
    -from django.utils.safestring import mark_safe
    +from django.utils.functional import cached_property
    +from django.utils.html import format_html
     
     from nautobot.dcim.choices import DeviceFaceChoices, DeviceRedundancyGroupFailoverStrategyChoices, SubdeviceRoleChoices
     from nautobot.dcim.models.device_components import (
    @@ -279,9 +279,11 @@ def clean(self):
                     url = f"{reverse('dcim:device_list')}?manufacturer_id={self.manufacturer_id}&device_type_id={self.pk}"
                     raise ValidationError(
                         {
    -                        "u_height": mark_safe(
    -                            f'Unable to set 0U height: Found <a href="{url}">{racked_instance_count} instances</a> already '
    -                            f"mounted within racks."
    +                        "u_height": format_html(
    +                            "Unable to set 0U height: "
    +                            'Found <a href="{}">{} instances</a> already mounted within racks.',
    +                            url,
    +                            racked_instance_count,
                             )
                         }
                     )
    
  • nautobot/dcim/views.py+5 6 modified
    @@ -12,8 +12,7 @@
         modelformset_factory,
     )
     from django.shortcuts import get_object_or_404, redirect, render
    -from django.utils.html import escape
    -from django.utils.safestring import mark_safe
    +from django.utils.html import format_html
     from django.views.generic import View
     from django_tables2 import RequestConfig
     
    @@ -2961,8 +2960,8 @@ def post(self, request, pk):
     
                 if membership_form.is_valid():
                     membership_form.save()
    -                msg = f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
    -                messages.success(request, mark_safe(msg))
    +                msg = format_html('Added member <a href="{}">{}</a>', device.get_absolute_url(), device)
    +                messages.success(request, msg)
     
                     if "_addanother" in request.POST:
                         return redirect(request.get_full_path())
    @@ -3011,8 +3010,8 @@ def post(self, request, pk):
             # Protect master device from being removed
             virtual_chassis = VirtualChassis.objects.filter(master=device).first()
             if virtual_chassis is not None:
    -            msg = f"Unable to remove master device {escape(device)} from the virtual chassis."
    -            messages.error(request, mark_safe(msg))
    +            msg = format_html("Unable to remove master device {} from the virtual chassis.", device)
    +            messages.error(request, msg)
                 return redirect(device.get_absolute_url())
     
             if form.is_valid():
    
  • nautobot/extras/forms/forms.py+4 4 modified
    @@ -648,7 +648,7 @@ def __init__(self, *args, **kwargs):
             self.fields["provided_contents"] = CSVMultipleChoiceField(
                 choices=get_git_datasource_content_choices(),
                 required=False,
    -            help_text=mark_safe(
    +            help_text=mark_safe(  # noqa: S308
                     "The data types this repository provides. Multiple values must be comma-separated and wrapped in "
                     'double quotes (e.g. <code>"extras.job,extras.configcontext"</code>).'
                 ),
    @@ -1352,7 +1352,7 @@ class StatusCSVForm(CustomFieldModelCSVForm):
         content_types = CSVMultipleContentTypeField(
             feature="statuses",
             choices_as_strings=True,
    -        help_text=mark_safe(
    +        help_text=mark_safe(  # noqa: S308
                 "The object types to which this status applies. Multiple values "
                 "must be comma-separated and wrapped in double quotes. (e.g. "
                 '<code>"dcim.device,dcim.rack"</code>)'
    @@ -1364,7 +1364,7 @@ class Meta:
             model = Status
             fields = Status.csv_headers
             help_texts = {
    -            "color": mark_safe("RGB color in hexadecimal (e.g. <code>00ff00</code>)"),
    +            "color": mark_safe("RGB color in hexadecimal (e.g. <code>00ff00</code>)"),  # noqa: S308
             }
     
     
    @@ -1427,7 +1427,7 @@ class Meta:
             model = Tag
             fields = Tag.csv_headers
             help_texts = {
    -            "color": mark_safe("RGB color in hexadecimal (e.g. <code>00ff00</code>)"),
    +            "color": mark_safe("RGB color in hexadecimal (e.g. <code>00ff00</code>)"),  # noqa: S308
             }
     
     
    
  • nautobot/extras/models/customfields.py+2 2 modified
    @@ -12,7 +12,7 @@
     from django.db import models
     from django.forms.widgets import TextInput
     from django.urls import reverse
    -from django.utils.safestring import mark_safe
    +from django.utils.html import format_html
     
     from nautobot.extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices
     from nautobot.extras.models import ChangeLoggedModel
    @@ -546,7 +546,7 @@ def to_form_field(
                     field.validators = [
                         RegexValidator(
                             regex=self.validation_regex,
    -                        message=mark_safe(f"Values must match this regex: <code>{self.validation_regex}</code>"),
    +                        message=format_html("Values must match this regex: <code>{}</code>", self.validation_regex),
                         )
                     ]
     
    
  • nautobot/extras/models/models.py+5 2 modified
    @@ -326,12 +326,15 @@ class CustomLink(BaseModel, ChangeLoggedModel, NotesMixin):
         name = models.CharField(max_length=100, unique=True)
         text = models.CharField(
             max_length=500,
    -        help_text="Jinja2 template code for link text. Reference the object as <code>{{ obj }}</code> such as <code>{{ obj.platform.slug }}</code>. Links which render as empty text will not be displayed.",
    +        help_text="Jinja2 template code for link text. "
    +        "Reference the object as <code>{{ obj }}</code> such as <code>{{ obj.platform.slug }}</code>. "
    +        "Links which render as empty text will not be displayed.",
         )
         target_url = models.CharField(
             max_length=500,
             verbose_name="URL",
    -        help_text="Jinja2 template code for link URL. Reference the object as <code>{{ obj }}</code> such as <code>{{ obj.platform.slug }}</code>.",
    +        help_text="Jinja2 template code for link URL. "
    +        "Reference the object as <code>{{ obj }}</code> such as <code>{{ obj.platform.slug }}</code>.",
         )
         weight = models.PositiveSmallIntegerField(default=100)
         group_name = models.CharField(
    
  • nautobot/extras/models/relationships.py+13 8 modified
    @@ -9,7 +9,7 @@
     from django.db.models import Q
     from django.urls import reverse
     from django.urls.exceptions import NoReverseMatch
    -from django.utils.safestring import mark_safe
    +from django.utils.html import format_html
     
     from nautobot.core.fields import AutoSlugField
     from nautobot.core.models import BaseModel
    @@ -24,6 +24,7 @@
         widgets,
     )
     from nautobot.utilities.querysets import RestrictedQuerySet
    +from nautobot.utilities.templatetags.helpers import bettertitle
     
     
     logger = logging.getLogger(__name__)
    @@ -275,9 +276,10 @@ def required_related_objects_errors(
                     if output_for == "ui":
                         try:
                             add_url = reverse(get_route_for_model(required_model_class, "add"))
    -                        hint = (
    -                            f"<a target='_blank' href='{add_url}'>Click here</a> to create "
    -                            f"a {required_model_meta.verbose_name}."
    +                        hint = format_html(
    +                            '<a target="_blank" href="{}">Click here</a> to create a {}.',
    +                            add_url,
    +                            required_model_meta.verbose_name,
                             )
                         except NoReverseMatch:
                             pass
    @@ -289,11 +291,14 @@ def required_related_objects_errors(
                         except NoReverseMatch:
                             pass
     
    -                error_message = mark_safe(
    -                    f"{name_plural[0].upper()}{name_plural[1:]} require "
    -                    f"{num_required_verbose} {required_model_meta.verbose_name}, but no "
    -                    f"{required_model_meta.verbose_name_plural} exist yet. {hint}"
    +                error_message = format_html(
    +                    "{} require {} {}, but no {} exist yet. ",
    +                    bettertitle(name_plural),
    +                    num_required_verbose,
    +                    required_model_meta.verbose_name,
    +                    required_model_meta.verbose_name_plural,
                     )
    +                error_message += hint
                     field_errors[field_key].append(error_message)
     
                 if initial_data is not None:
    
  • nautobot/extras/tables.py+1 2 modified
    @@ -1,7 +1,6 @@
     import django_tables2 as tables
     from django.conf import settings
     from django.utils.html import format_html
    -from django.utils.safestring import mark_safe
     from django_tables2.utils import Accessor
     from jsonschema.exceptions import ValidationError as JSONSchemaValidationError
     
    @@ -255,7 +254,7 @@ class Meta(BaseTable.Meta):
     
         def render_description(self, record):
             if record.description:
    -            return mark_safe(render_markdown(record.description))
    +            return render_markdown(record.description)
             return self.default
     
     
    
  • nautobot/extras/templatetags/computed_fields.py+6 13 modified
    @@ -1,7 +1,6 @@
     from django import template
     from django.contrib.contenttypes.models import ContentType
    -from django.utils.html import escape
    -from django.utils.safestring import mark_safe
    +from django.utils.html import format_html_join
     
     from nautobot.extras.models import ComputedField
     
    @@ -27,14 +26,8 @@ def computed_fields(context, obj, advanced_ui=None):
         if not computed_fields:
             return ""
     
    -    template_code = ""
    -
    -    for label, value in fields.items():
    -        escaped_label = escape(label)
    -        template_code += f"""
    -            <tr>
    -                <td><span title="{escaped_label}">{escaped_label}</span></td>
    -                <td>{escape(value)}</td>
    -            <tr>
    -            """
    -    return mark_safe(template_code)
    +    return format_html_join(
    +        "\n",
    +        '<tr><td><span title="{}">{}</span></td><td>{}</td></tr>',
    +        ((label, label, value) for label, value in fields.items()),
    +    )
    
  • nautobot/extras/templatetags/custom_links.py+19 12 modified
    @@ -2,6 +2,7 @@
     
     from django import template
     from django.contrib.contenttypes.models import ContentType
    +from django.utils.html import format_html
     from django.utils.safestring import mark_safe
     
     from nautobot.extras.models import CustomLink
    @@ -40,7 +41,7 @@ def custom_links(context, obj):
             "user": context["user"],  # django.contrib.auth.context_processors.auth
             "perms": context["perms"],  # django.contrib.auth.context_processors.auth
         }
    -    template_code = ""
    +    template_code = mark_safe("")  # noqa: S308
         group_names = OrderedDict()
     
         for cl in links:
    @@ -57,31 +58,37 @@ def custom_links(context, obj):
                     if text_rendered:
                         link_rendered = render_jinja2(cl.target_url, link_context)
                         link_target = ' target="_blank"' if cl.new_window else ""
    -                    template_code += LINK_BUTTON.format(link_rendered, link_target, cl.button_class, text_rendered)
    +                    template_code += format_html(
    +                        LINK_BUTTON, link_rendered, link_target, cl.button_class, text_rendered
    +                    )
                 except Exception as e:
    -                template_code += (
    -                    f'<a class="btn btn-sm btn-default" disabled="disabled" title="{e}">'
    -                    f'<i class="mdi mdi-alert"></i> {cl.name}</a>\n'
    +                template_code += format_html(
    +                    '<a class="btn btn-sm btn-default" disabled="disabled" title="{}">'
    +                    '<i class="mdi mdi-alert"></i> {}</a>\n',
    +                    e,
    +                    cl.name,
                     )
     
         # Add grouped links to template
         for group, links in group_names.items():
    -        links_rendered = []
    +        links_rendered = mark_safe("")  # noqa: S308
     
             for cl in links:
                 try:
                     text_rendered = render_jinja2(cl.text, link_context)
                     if text_rendered:
                         link_target = ' target="_blank"' if cl.new_window else ""
                         link_rendered = render_jinja2(cl.target_url, link_context)
    -                    links_rendered.append(GROUP_LINK.format(link_rendered, link_target, text_rendered))
    +                    links_rendered += format_html(GROUP_LINK, link_rendered, link_target, text_rendered)
                 except Exception as e:
    -                links_rendered.append(
    -                    f'<li><a disabled="disabled" title="{e}"><span class="text-muted">'
    -                    f'<i class="mdi mdi-alert"></i> {cl.name}</span></a></li>'
    +                links_rendered += format_html(
    +                    '<li><a disabled="disabled" title="{}"><span class="text-muted">'
    +                    '<i class="mdi mdi-alert"></i> {}</span></a></li>',
    +                    e,
    +                    cl.name,
                     )
     
             if links_rendered:
    -            template_code += GROUP_BUTTON.format(links[0].button_class, group, "".join(links_rendered))
    +            template_code += format_html(GROUP_BUTTON, links[0].button_class, group, links_rendered)
     
    -    return mark_safe(template_code)
    +    return template_code
    
  • nautobot/extras/templatetags/job_buttons.py+34 19 modified
    @@ -3,6 +3,7 @@
     from django import template
     from django.contrib.contenttypes.models import ContentType
     from django.urls import reverse
    +from django.utils.html import format_html
     from django.utils.safestring import mark_safe
     
     from nautobot.extras.models import JobButton
    @@ -87,10 +88,11 @@ def job_buttons(context, obj):
             "user": context["user"],  # django.contrib.auth.context_processors.auth
             "perms": context["perms"],  # django.contrib.auth.context_processors.auth
         }
    -    buttons_html = forms_html = ""
    +    buttons_html = forms_html = mark_safe("")  # noqa: S308
         group_names = OrderedDict()
     
    -    hidden_inputs = HIDDEN_INPUTS.format(
    +    hidden_inputs = format_html(
    +        HIDDEN_INPUTS,
             csrf_token=context["csrf_token"],
             object_pk=obj.pk,
             object_model_name=f"{content_type.app_label}.{content_type.model}",
    @@ -121,22 +123,24 @@ def job_buttons(context, obj):
                     if text_rendered:
                         template_args["button_text"] = text_rendered
                         if jb.confirmation:
    -                        buttons_html += CONFIRM_BUTTON.format(**template_args)
    -                        forms_html += CONFIRM_MODAL.format(**template_args)
    +                        buttons_html += format_html(CONFIRM_BUTTON, **template_args)
    +                        forms_html += format_html(CONFIRM_MODAL, **template_args)
                         else:
    -                        buttons_html += NO_CONFIRM_BUTTON.format(**template_args)
    -                        forms_html += NO_CONFIRM_FORM.format(**template_args)
    +                        buttons_html += format_html(NO_CONFIRM_BUTTON, **template_args)
    +                        forms_html += format_html(NO_CONFIRM_FORM, **template_args)
                 except Exception as e:
    -                buttons_html += (
    -                    f'<a class="btn btn-sm btn-default" disabled="disabled" title="{e}">'
    -                    f'<i class="mdi mdi-alert"></i> {jb.name}</a>\n'
    +                buttons_html += format_html(
    +                    '<a class="btn btn-sm btn-default" disabled="disabled" title="{}">'
    +                    '<i class="mdi mdi-alert"></i> {}</a>\n',
    +                    e,
    +                    jb.name,
                     )
     
         # Add grouped buttons to template
         for group_name, buttons in group_names.items():
             group_button_class = buttons[0].button_class
     
    -        buttons_rendered = ""
    +        buttons_rendered = mark_safe("")  # noqa: S308
     
             for jb in buttons:
                 template_args = {
    @@ -154,23 +158,34 @@ def job_buttons(context, obj):
                     if text_rendered:
                         template_args["button_text"] = text_rendered
                         if jb.confirmation:
    -                        buttons_rendered += "<li>" + CONFIRM_BUTTON.format(**template_args) + "</li>"
    -                        forms_html += CONFIRM_MODAL.format(**template_args)
    +                        buttons_rendered += (
    +                            mark_safe("<li>")  # noqa: S308
    +                            + format_html(CONFIRM_BUTTON, **template_args)
    +                            + mark_safe("</li>")  # noqa: S308
    +                        )
    +                        forms_html += format_html(CONFIRM_MODAL, **template_args)
                         else:
    -                        buttons_rendered += "<li>" + NO_CONFIRM_BUTTON.format(**template_args) + "</li>"
    -                        forms_html += NO_CONFIRM_FORM.format(**template_args)
    +                        buttons_rendered += (
    +                            mark_safe("<li>")  # noqa: S308
    +                            + format_html(NO_CONFIRM_BUTTON, **template_args)
    +                            + mark_safe("</li>")  # noqa: S308
    +                        )
    +                        forms_html += format_html(NO_CONFIRM_FORM, **template_args)
                 except Exception as e:
    -                buttons_rendered += (
    -                    f'<li><a disabled="disabled" title="{e}"><span class="text-muted">'
    -                    f'<i class="mdi mdi-alert"></i> {jb.name}</span></a></li>'
    +                buttons_rendered += format_html(
    +                    '<li><a disabled="disabled" title="{}"><span class="text-muted">'
    +                    '<i class="mdi mdi-alert"></i> {}</span></a></li>',
    +                    e,
    +                    jb.name,
                     )
     
             if buttons_rendered:
    -            buttons_html += GROUP_DROPDOWN.format(
    +            buttons_html += format_html(
    +                GROUP_DROPDOWN,
                     group_button_class=group_button_class,
                     group_name=group_name,
                     grouped_buttons=buttons_rendered,
                 )
     
         # We want all of the buttons first and then any modals and forms so the buttons render properly
    -    return mark_safe(buttons_html + forms_html)
    +    return buttons_html + forms_html
    
  • nautobot/extras/templatetags/plugins.py+1 1 modified
    @@ -52,7 +52,7 @@ def _get_registered_content(obj, method, template_context, return_html=True):
         if not return_html:
             return objects
     
    -    return mark_safe(html)
    +    return mark_safe(html)  # noqa: S308
     
     
     @register.simple_tag(takes_context=True)
    
  • nautobot/extras/tests/test_customfields.py+1 1 modified
    @@ -2188,7 +2188,7 @@ def test_custom_field_table_render(self):
                 "url_field": '<a href="http://example.com/2">http://example.com/2</a>',
                 "choice_field": '<span class="label label-default">Bar</span>',
                 "multi_choice_field": (
    -                '<span class="label label-default">Bar</span> <span class="label label-default">Baz</span> '
    +                '<span class="label label-default">Bar</span> <span class="label label-default">Baz</span>'
                 ),
             }
     
    
  • nautobot/extras/tests/test_relationships.py+2 2 modified
    @@ -1138,13 +1138,13 @@ def required_relationships_test(self, interact_with="ui"):
                     ],
                     "expected_errors": {
                         "api": {
    -                        "objects_nonexistent": "Circuit types require a platform, but no platforms exist yet. "
    +                        "objects_nonexistent": "Circuit Types require a platform, but no platforms exist yet. "
                             "Create a platform by posting to /api/dcim/platforms/",
                             "objects_not_specified": 'You need to specify ["relationships"]["circuittype-platform-o2o"]'
                             '["destination"]["objects"].',
                         },
                         "ui": {
    -                        "objects_nonexistent": "Circuit types require a platform, but no platforms exist yet.",
    +                        "objects_nonexistent": "Circuit Types require a platform, but no platforms exist yet.",
                             "objects_not_specified": "You need to select a platform.",
                         },
                     },
    
  • nautobot/extras/tests/test_views.py+186 3 modified
    @@ -8,9 +8,19 @@
     from django.test import override_settings
     from django.urls import reverse
     from django.utils import timezone
    +from django.utils.html import format_html
     from unittest import mock
     
    -from nautobot.dcim.models import ConsolePort, Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
    +from nautobot.dcim.models import (
    +    ConsolePort,
    +    Device,
    +    DeviceRole,
    +    DeviceType,
    +    Interface,
    +    LocationType,
    +    Manufacturer,
    +    Site,
    +)
     from nautobot.dcim.tests import test_views
     from nautobot.extras.choices import (
         CustomFieldTypeChoices,
    @@ -22,6 +32,7 @@
     )
     from nautobot.extras.constants import HTTP_CONTENT_TYPE_JSON
     from nautobot.extras.models import (
    +    ComputedField,
         ConfigContext,
         ConfigContextSchema,
         CustomField,
    @@ -44,7 +55,6 @@
         Status,
         Tag,
         Webhook,
    -    ComputedField,
     )
     from nautobot.extras.tests.constants import BIG_GRAPHQL_DEVICE_QUERY
     from nautobot.extras.tests.test_relationships import RequiredRelationshipTestMixin
    @@ -129,6 +139,63 @@ def setUpTestData(cls):
             cls.slug_test_object = "Computed Field Five"
     
     
    +class ComputedFieldRenderingTestCase(TestCase):
    +    """Tests for the inclusion of ComputedFields, distinct from tests of the ComputedField views themselves."""
    +
    +    user_permissions = ["dcim.view_locationtype"]
    +
    +    def setUp(self):
    +        super().setUp()
    +        self.computedfield = ComputedField(
    +            content_type=ContentType.objects.get_for_model(LocationType),
    +            slug="test",
    +            label="Computed Field",
    +            template="FOO {{ obj.name }} BAR",
    +            fallback_value="Fallback Value",
    +            weight=100,
    +        )
    +        self.computedfield.validated_save()
    +        self.location_type = LocationType.objects.get(name="Campus")
    +
    +    def test_view_object_with_computed_field(self):
    +        """Ensure that the computed field template is rendered."""
    +        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertIn(f"FOO {self.location_type.name} BAR", content, content)
    +
    +    def test_view_object_with_computed_field_fallback_value(self):
    +        """Ensure that the fallback_value is rendered if the template fails to render."""
    +        # Make the template invalid to demonstrate the fallback value
    +        self.computedfield.template = "FOO {{ obj."
    +        self.computedfield.validated_save()
    +        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertIn("Fallback Value", content, content)
    +
    +    def test_view_object_with_computed_field_unsafe_template(self):
    +        """Ensure that computed field templates can't be used as an XSS vector."""
    +        self.computedfield.template = '<script>alert("Hello world!"</script>'
    +        self.computedfield.validated_save()
    +        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertNotIn("<script>alert", content, content)
    +        self.assertIn("&lt;script&gt;alert", content, content)
    +
    +    def test_view_object_with_computed_field_unsafe_fallback_value(self):
    +        """Ensure that computed field fallback values can't be used as an XSS vector."""
    +        self.computedfield.template = "FOO {{ obj."
    +        self.computedfield.fallback_value = '<script>alert("Hello world!"</script>'
    +        self.computedfield.validated_save()
    +        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertNotIn("<script>alert", content, content)
    +        self.assertIn("&lt;script&gt;alert", content, content)
    +
    +
     # TODO: Change base class to PrimaryObjectViewTestCase
     # Blocked by absence of standard create/edit, bulk create views
     class ConfigContextTestCase(
    @@ -441,7 +508,9 @@ def test_create_object_with_constrained_permission(self):
             super().test_create_object_with_constrained_permission()
     
     
    -class CustomLinkTest(TestCase):
    +class CustomLinkRenderingTestCase(TestCase):
    +    """Tests for the inclusion of CustomLinks, distinct from tests of the CustomLink views themselves."""
    +
         user_permissions = ["dcim.view_site"]
     
         def test_view_object_with_custom_link(self):
    @@ -462,6 +531,65 @@ def test_view_object_with_custom_link(self):
             content = extract_page_body(response.content.decode(response.charset))
             self.assertIn(f"FOO {site.name} BAR", content, content)
     
    +    def test_view_object_with_unsafe_custom_link_text(self):
    +        """Ensure that custom links can't be used as a vector for injecting scripts or breaking HTML."""
    +        customlink = CustomLink(
    +            content_type=ContentType.objects.get_for_model(Site),
    +            name="Test",
    +            text='<script>alert("Hello world!")</script>',
    +            target_url="http://example.com/?location=None",
    +            new_window=False,
    +        )
    +        customlink.validated_save()
    +        site = Site(name="Test Site", slug="test-site")
    +        site.save()
    +
    +        response = self.client.get(site.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertNotIn("<script>alert", content, content)
    +        self.assertIn("&lt;script&gt;alert", content, content)
    +        self.assertIn(format_html('<a href="{}"', customlink.target_url), content, content)
    +
    +    def test_view_object_with_unsafe_custom_link_url(self):
    +        """Ensure that custom links can't be used as a vector for injecting scripts or breaking HTML."""
    +        customlink = CustomLink(
    +            content_type=ContentType.objects.get_for_model(Site),
    +            name="Test",
    +            text="Hello",
    +            target_url='"><script>alert("Hello world!")</script><a href="',
    +            new_window=False,
    +        )
    +        customlink.validated_save()
    +        site = Site(name="Test Site", slug="test-site")
    +        site.save()
    +
    +        response = self.client.get(site.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertNotIn("<script>alert", content, content)
    +        self.assertIn("&lt;script&gt;alert", content, content)
    +        self.assertIn(format_html('<a href="{}"', customlink.target_url), content, content)
    +
    +    def test_view_object_with_unsafe_custom_link_name(self):
    +        """Ensure that custom links can't be used as a vector for injecting scripts or breaking HTML."""
    +        customlink = CustomLink(
    +            content_type=ContentType.objects.get_for_model(Site),
    +            name='<script>alert("Hello World")</script>',
    +            text="Hello",
    +            target_url="http://example.com/?site={{ obj.name ",  # intentionally bad jinja2 to trigger error case
    +            new_window=False,
    +        )
    +        customlink.validated_save()
    +        site = Site(name="Test Site", slug="test-site")
    +        site.save()
    +
    +        response = self.client.get(site.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertNotIn("<script>alert", content, content)
    +        self.assertIn("&lt;script&gt;alert", content, content)
    +
     
     class DynamicGroupTestCase(
         ViewTestCases.CreateObjectViewTestCase,
    @@ -1877,6 +2005,61 @@ def setUpTestData(cls):
             }
     
     
    +class JobButtonRenderingTestCase(TestCase):
    +    """Tests for the rendering of JobButtons, distinct from tests of the JobButton views themselves."""
    +
    +    user_permissions = ["dcim.view_locationtype"]
    +
    +    def setUp(self):
    +        super().setUp()
    +        self.job_button = JobButton(
    +            name="JobButton",
    +            text="JobButton {{ obj.name }}",
    +            job=Job.objects.get(job_class_name="TestJobButtonReceiverSimple"),
    +            confirmation=False,
    +        )
    +        self.job_button.validated_save()
    +        self.job_button.content_types.add(ContentType.objects.get_for_model(LocationType))
    +        self.location_type = LocationType.objects.get(name="Campus")
    +
    +    def test_view_object_with_job_button(self):
    +        """Ensure that the job button is rendered."""
    +        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertIn(f"JobButton {self.location_type.name}", content, content)
    +
    +    def test_view_object_with_unsafe_text(self):
    +        """Ensure that JobButton text can't be used as a vector for XSS."""
    +        self.job_button.text = '<script>alert("Hello world!")</script>'
    +        self.job_button.validated_save()
    +        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertNotIn("<script>alert", content, content)
    +        self.assertIn("&lt;script&gt;alert", content, content)
    +
    +        # Make sure grouped rendering is safe too
    +        self.job_button.group = '<script>alert("Goodbye")</script>'
    +        self.job_button.validated_save()
    +        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertNotIn("<script>alert", content, content)
    +        self.assertIn("&lt;script&gt;alert", content, content)
    +
    +    def test_view_object_with_unsafe_name(self):
    +        """Ensure that JobButton names can't be used as a vector for XSS."""
    +        self.job_button.text = "JobButton {{ obj"
    +        self.job_button.name = '<script>alert("Yo")</script>'
    +        self.job_button.validated_save()
    +        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertNotIn("<script>alert", content, content)
    +        self.assertIn("&lt;script&gt;alert", content, content)
    +
    +
     # TODO: Convert to StandardTestCases.Views
     class ObjectChangeTestCase(TestCase):
         user_permissions = ("extras.view_objectchange",)
    
  • nautobot/extras/views.py+12 13 modified
    @@ -13,9 +13,8 @@
     from django.shortcuts import get_object_or_404, redirect, render
     from django.urls import reverse
     from django.utils import timezone
    -from django.utils.html import escape
    +from django.utils.html import format_html
     from django.utils.http import is_safe_url
    -from django.utils.safestring import mark_safe
     from django.views.generic import View
     from django.template.loader import get_template, TemplateDoesNotExist
     from django_tables2 import RequestConfig
    @@ -407,10 +406,10 @@ def post(self, request, *args, **kwargs):
                     msg = f"{verb} {self.queryset.model._meta.verbose_name}"
                     logger.info(f"{msg} {obj} (PK: {obj.pk})")
                     if hasattr(obj, "get_absolute_url"):
    -                    msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
    +                    msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
                     else:
    -                    msg = f"{msg} {escape(obj)}"
    -                messages.success(request, mark_safe(msg))
    +                    msg = format_html("{} {}", msg, obj)
    +                messages.success(request, msg)
     
                     if "_addanother" in request.POST:
                         # If the object has clone_fields, pre-populate a new instance of the form
    @@ -645,10 +644,10 @@ def post(self, request, *args, **kwargs):
                     msg = f"{verb} {self.queryset.model._meta.verbose_name}"
                     logger.info(f"{msg} {obj} (PK: {obj.pk})")
                     if hasattr(obj, "get_absolute_url"):
    -                    msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
    +                    msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
                     else:
    -                    msg = f"{msg} {escape(obj)}"
    -                messages.success(request, mark_safe(msg))
    +                    msg = format_html("{} {}", msg, obj)
    +                messages.success(request, msg)
     
                     if "_addanother" in request.POST:
                         # If the object has clone_fields, pre-populate a new instance of the form
    @@ -1616,8 +1615,8 @@ def post(self, request, pk):
                 request=copy_safe_request(request),
                 commit=True,
             )
    -        msg = f'Job enqueued. <a href="{result.get_absolute_url()}">Click here for the results.</a>'
    -        messages.info(request=request, message=mark_safe(msg))
    +        msg = format_html('Job enqueued. <a href="{}">Click here for the results.</a>', result.get_absolute_url())
    +        messages.info(request=request, message=msg)
             return redirect(post_data["redirect_path"])
     
     
    @@ -1973,10 +1972,10 @@ def post(self, request, *args, **kwargs):
                     msg = f"{verb} {self.queryset.model._meta.verbose_name}"
                     logger.info(f"{msg} {obj} (PK: {obj.pk})")
                     if hasattr(obj, "get_absolute_url"):
    -                    msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
    +                    msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
                     else:
    -                    msg = f"{msg} {escape(obj)}"
    -                messages.success(request, mark_safe(msg))
    +                    msg = format_html("{} {}", msg, obj)
    +                messages.success(request, msg)
     
                     if "_addanother" in request.POST:
                         # If the object has clone_fields, pre-populate a new instance of the form
    
  • nautobot/ipam/tables.py+1 1 modified
    @@ -29,7 +29,7 @@
         VRF,
     )
     
    -AVAILABLE_LABEL = mark_safe('<span class="label label-success">Available</span>')
    +AVAILABLE_LABEL = mark_safe('<span class="label label-success">Available</span>')  # noqa: S308
     
     UTILIZATION_GRAPH = """
     {% load helpers %}
    
  • nautobot/utilities/error_handlers.py+9 8 modified
    @@ -1,5 +1,5 @@
     from django.contrib import messages
    -from django.utils.html import escape
    +from django.utils.html import escape, format_html
     from django.utils.safestring import mark_safe
     
     
    @@ -9,18 +9,19 @@ def handle_protectederror(obj_list, request, e):
         """
         protected_objects = list(e.protected_objects)
         protected_count = len(protected_objects) if len(protected_objects) <= 50 else "More than 50"
    -    err_message = (
    -        f"Unable to delete <strong>{', '.join(str(obj) for obj in obj_list)}</strong>. "
    -        f"{protected_count} dependent objects were found: "
    +    err_message = format_html(
    +        "Unable to delete <strong>{}</strong>. {} dependent objects were found: ",
    +        ", ".join(str(obj) for obj in obj_list),
    +        protected_count,
         )
     
         # Append dependent objects to error message
         dependent_objects = []
         for dependent in protected_objects[:50]:
             if hasattr(dependent, "get_absolute_url"):
    -            dependent_objects.append(f'<a href="{dependent.get_absolute_url()}">{escape(dependent)}</a>')
    +            dependent_objects.append(format_html('<a href="{}">{}</a>', dependent.get_absolute_url(), dependent))
             else:
    -            dependent_objects.append(str(dependent))
    -    err_message += ", ".join(dependent_objects)
    +            dependent_objects.append(escape(str(dependent)))
    +    err_message += mark_safe(", ".join(dependent_objects))  # noqa: S308
     
    -    messages.error(request, mark_safe(err_message))
    +    messages.error(request, err_message)
    
  • nautobot/utilities/tables.py+26 31 modified
    @@ -5,7 +5,7 @@
     from django.core.exceptions import FieldDoesNotExist
     from django.db.models.fields.related import RelatedField
     from django.urls import reverse
    -from django.utils.html import escape, format_html
    +from django.utils.html import escape, format_html, format_html_join
     from django.utils.safestring import mark_safe
     from django.utils.text import Truncator
     from django_tables2.data import TableQuerysetData
    @@ -171,7 +171,7 @@ def __init__(self, *args, **kwargs):
     
         @property
         def header(self):
    -        return mark_safe('<input type="checkbox" class="toggle" title="Toggle all" />')
    +        return mark_safe('<input type="checkbox" class="toggle" title="Toggle all" />')  # noqa: S308
     
     
     class BooleanColumn(tables.Column):
    @@ -268,7 +268,7 @@ def render(self, record, bound_column, value):  # pylint: disable=arguments-diff
                 name = bound_column.name
                 css_class = getattr(record, f"get_{name}_class")()
                 label = getattr(record, f"get_{name}_display")()
    -            return mark_safe(f'<span class="label label-{css_class}">{label}</span>')
    +            return format_html('<span class="label label-{}">{}</span>', css_class, label)
             return self.default
     
     
    @@ -278,7 +278,7 @@ class ColorColumn(tables.Column):
         """
     
         def render(self, value):
    -        return mark_safe(f'<span class="label color-block" style="background-color: #{value}">&nbsp;</span>')
    +        return format_html('<span class="label color-block" style="background-color: #{}">&nbsp;</span>', value)
     
     
     class ColoredLabelColumn(tables.TemplateColumn):
    @@ -315,7 +315,7 @@ def render(self, record, value):  # pylint: disable=arguments-differ
                 url = reverse(self.viewname, kwargs=self.view_kwargs)
                 if self.url_params:
                     url += "?" + "&".join([f"{k}={getattr(record, v)}" for k, v in self.url_params.items()])
    -            return mark_safe(f'<a href="{url}">{value}</a>')
    +            return format_html('<a href="{}">{}</a>', url, value)
             return value
     
     
    @@ -403,20 +403,18 @@ def __init__(self, customfield, *args, **kwargs):
             super().__init__(*args, **kwargs)
     
         def render(self, record, bound_column, value):  # pylint: disable=arguments-differ
    -        template = ""
             if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
                 template = render_boolean(value)
             elif self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
    -            for v in value:
    -                template += format_html('<span class="label label-default">{}</span> ', v)
    +            template = format_html_join(" ", '<span class="label label-default">{}</span>', ((v,) for v in value))
             elif self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
                 template = format_html('<span class="label label-default">{}</span>', value)
             elif self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
                 template = format_html('<a href="{}">{}</a>', value, value)
             else:
                 template = escape(value)
     
    -        return mark_safe(template)
    +        return template
     
     
     class RelationshipColumn(tables.Column):
    @@ -445,30 +443,27 @@ def render(self, record, value):  # pylint: disable=arguments-differ
                 else:
                     value = [v for v in value if v.destination_id == record.id]
     
    -        template = ""
             # Handle Symmetric Relationships
             # List `value` could be empty here [] after the filtering from above
             if len(value) < 1:
                 return "—"
    -        else:
    -            # Handle Relationships on the many side.
    -            if self.relationship.has_many(self.peer_side):
    -                v = value[0]
    -                meta = type(v.get_peer(record))._meta
    -                name = meta.verbose_name_plural if len(value) > 1 else meta.verbose_name
    -                template += format_html(
    -                    '<a href="{}?relationship={}&{}_id={}">{} {}</a>',
    -                    reverse("extras:relationshipassociation_list"),
    -                    self.relationship.slug,
    -                    self.side,
    -                    record.id,
    -                    len(value),
    -                    name,
    -                )
    -            # Handle Relationships on the one side.
    -            else:
    -                v = value[0]
    -                peer = v.get_peer(record)
    -                template += format_html('<a href="{}">{}</a>', peer.get_absolute_url(), peer)
     
    -        return mark_safe(template)
    +        # Handle Relationships on the many side.
    +        if self.relationship.has_many(self.peer_side):
    +            v = value[0]
    +            meta = type(v.get_peer(record))._meta
    +            name = meta.verbose_name_plural if len(value) > 1 else meta.verbose_name
    +            return format_html(
    +                '<a href="{}?relationship={}&{}_id={}">{} {}</a>',
    +                reverse("extras:relationshipassociation_list"),
    +                self.relationship.slug,
    +                self.side,
    +                record.id,
    +                len(value),
    +                name,
    +            )
    +        # Handle Relationships on the one side.
    +        else:
    +            v = value[0]
    +            peer = v.get_peer(record)
    +            return format_html('<a href="{}">{}</a>', peer.get_absolute_url(), peer)
    
  • nautobot/utilities/templatetags/helpers.py+9 9 modified
    @@ -17,9 +17,9 @@
     from nautobot.utilities.forms import TableConfigForm
     from nautobot.utilities.utils import foreground_color, get_route_for_model, UtilizationData
     
    -HTML_TRUE = '<span class="text-success"><i class="mdi mdi-check-bold" title="Yes"></i></span>'
    -HTML_FALSE = '<span class="text-danger"><i class="mdi mdi-close-thick" title="No"></i></span>'
    -HTML_NONE = '<span class="text-muted">&mdash;</span>'
    +HTML_TRUE = mark_safe('<span class="text-success"><i class="mdi mdi-check-bold" title="Yes"></i></span>')  # noqa: S308
    +HTML_FALSE = mark_safe('<span class="text-danger"><i class="mdi mdi-close-thick" title="No"></i></span>')  # noqa: S308
    +HTML_NONE = mark_safe('<span class="text-muted">&mdash;</span>')  # noqa: S308
     
     DEFAULT_SUPPORT_MESSAGE = (
         "If further assistance is required, please join the `#nautobot` channel "
    @@ -92,7 +92,7 @@ def placeholder(value):
         """
         if value:
             return value
    -    return mark_safe(HTML_NONE)
    +    return HTML_NONE
     
     
     @library.filter()
    @@ -116,7 +116,7 @@ def add_html_id(element_str, id_str):
         match = re.match(r"^(.*?<\w+) ?(.*)$", element_str, flags=re.DOTALL)
         if not match:
             return element_str
    -    return mark_safe(match.group(1) + format_html(' id="{}" ', id_str) + match.group(2))
    +    return mark_safe(match.group(1) + format_html(' id="{}" ', id_str) + match.group(2))  # noqa: S308
     
     
     @library.filter()
    @@ -147,10 +147,10 @@ def render_boolean(value):
             '<span class="text-danger"><i class="mdi mdi-close-thick" title="No"></i></span>'
         """
         if value is None:
    -        return mark_safe(HTML_NONE)
    +        return HTML_NONE
         if bool(value):
    -        return mark_safe(HTML_TRUE)
    -    return mark_safe(HTML_FALSE)
    +        return HTML_TRUE
    +    return HTML_FALSE
     
     
     @library.filter()
    @@ -173,7 +173,7 @@ def render_markdown(value):
         # Render Markdown
         html = markdown(value, extensions=["fenced_code", "tables"])
     
    -    return mark_safe(html)
    +    return mark_safe(html)  # noqa: S308
     
     
     @library.filter()
    
  • nautobot/utilities/utils.py+5 1 modified
    @@ -370,7 +370,11 @@ def render_jinja2(template_code, context):
         """
         rendering_engine = engines["jinja"]
         template = rendering_engine.from_string(template_code)
    -    return template.render(context=context)
    +    # For reasons unknown to me, django-jinja2 `template.render()` implicitly calls `mark_safe()` on the rendered text.
    +    # This is a security risk in general, especially so in our case because we're often using this function to render
    +    # a user-provided template and don't want to open ourselves up to script injection or similar issues.
    +    # There's no `mark_unsafe()` function, but concatenating a SafeString to an ordinary string (even "") suffices.
    +    return "" + template.render(context=context)
     
     
     def prepare_cloned_fields(instance):
    
  • poetry.lock+27 1 modified
    @@ -3306,6 +3306,32 @@ files = [
     [package.dependencies]
     pyasn1 = ">=0.1.3"
     
    +[[package]]
    +name = "ruff"
    +version = "0.1.6"
    +description = "An extremely fast Python linter and code formatter, written in Rust."
    +optional = false
    +python-versions = ">=3.7"
    +files = [
    +    {file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:88b8cdf6abf98130991cbc9f6438f35f6e8d41a02622cc5ee130a02a0ed28703"},
    +    {file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c549ed437680b6105a1299d2cd30e4964211606eeb48a0ff7a93ef70b902248"},
    +    {file = "ruff-0.1.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cf5f701062e294f2167e66d11b092bba7af6a057668ed618a9253e1e90cfd76"},
    +    {file = "ruff-0.1.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:05991ee20d4ac4bb78385360c684e4b417edd971030ab12a4fbd075ff535050e"},
    +    {file = "ruff-0.1.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87455a0c1f739b3c069e2f4c43b66479a54dea0276dd5d4d67b091265f6fd1dc"},
    +    {file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:683aa5bdda5a48cb8266fcde8eea2a6af4e5700a392c56ea5fb5f0d4bfdc0240"},
    +    {file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:137852105586dcbf80c1717facb6781555c4e99f520c9c827bd414fac67ddfb6"},
    +    {file = "ruff-0.1.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd98138a98d48a1c36c394fd6b84cd943ac92a08278aa8ac8c0fdefcf7138f35"},
    +    {file = "ruff-0.1.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0cd909d25f227ac5c36d4e7e681577275fb74ba3b11d288aff7ec47e3ae745"},
    +    {file = "ruff-0.1.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8fd1c62a47aa88a02707b5dd20c5ff20d035d634aa74826b42a1da77861b5ff"},
    +    {file = "ruff-0.1.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd89b45d374935829134a082617954120d7a1470a9f0ec0e7f3ead983edc48cc"},
    +    {file = "ruff-0.1.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:491262006e92f825b145cd1e52948073c56560243b55fb3b4ecb142f6f0e9543"},
    +    {file = "ruff-0.1.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ea284789861b8b5ca9d5443591a92a397ac183d4351882ab52f6296b4fdd5462"},
    +    {file = "ruff-0.1.6-py3-none-win32.whl", hash = "sha256:1610e14750826dfc207ccbcdd7331b6bd285607d4181df9c1c6ae26646d6848a"},
    +    {file = "ruff-0.1.6-py3-none-win_amd64.whl", hash = "sha256:4558b3e178145491e9bc3b2ee3c4b42f19d19384eaa5c59d10acf6e8f8b57e33"},
    +    {file = "ruff-0.1.6-py3-none-win_arm64.whl", hash = "sha256:03910e81df0d8db0e30050725a5802441c2022ea3ae4fe0609b76081731accbc"},
    +    {file = "ruff-0.1.6.tar.gz", hash = "sha256:1b09f29b16c6ead5ea6b097ef2764b42372aebe363722f1605ecbcd2b9207184"},
    +]
    +
     [[package]]
     name = "rx"
     version = "1.6.3"
    @@ -3972,4 +3998,4 @@ sso = ["social-auth-core"]
     [metadata]
     lock-version = "2.0"
     python-versions = ">=3.8,<3.12"
    -content-hash = "613ed75e90d9c9a0f1a4450fc2c4763ef79d4654c03a2066176408c6064d7661"
    +content-hash = "fd455e4a2825d191443cba74a3143a2f7afb25ab19ecdad85b0f25feebd94982"
    
  • pyproject.toml+22 0 modified
    @@ -199,6 +199,8 @@ flake8 = {version = "~6.0.0", python = "^3.8.1"}
     pylint = "~2.17.4"
     # Pylint extensions for Django
     pylint-django = "~2.5.3"
    +# Combination linter and code formatter
    +ruff = "~0.1.6"
     
     [tool.poetry.group.testing.dependencies]
     # Test code coverage measurement
    @@ -333,6 +335,26 @@ notes = """,
     # @patch changes the signature of a function it's applied to; don't raise "no-value-for-parameter" here
     signature-mutators=["unittest.mock.patch"]
     
    +[tool.ruff]
    +line-length = 120
    +target-version = "py38"
    +
    +[tool.ruff.lint]
    +select = [
    +    "E",  # pycodestyle
    +    "F",  # pyflakes
    +    # "I",  # isort
    +    "S308",  # flake8-bandit: suspicious-mark-safe-usage
    +    "W",  # pycodestyle
    +]
    +ignore = [
    +    "E501",  # pycodestyle: line-too-long
    +]
    +
    +[tool.ruff.lint.isort]
    +lines-after-imports = 2
    +force-sort-within-sections = true
    +
     [tool.towncrier]
     package = "nautobot"
     directory = "changes"
    
  • scripts/git-hooks/pre-commit+3 0 modified
    @@ -42,6 +42,9 @@ else
         invoke markdownlint || EXIT=1
     fi
     
    +echo "Check static analysis via ruff..."
    +invoke ruff || EXIT=1
    +
     echo "Check static analysis via pylint..."
     invoke pylint || EXIT=1
     
    
  • tasks.py+8 0 modified
    @@ -599,6 +599,13 @@ def pylint(context, target=None, recursive=False):
             run_command(context, command)
     
     
    +@task
    +def ruff(context, output_format="text"):
    +    """Run ruff to perform static analysis and linting."""
    +    command = f"ruff --output-format {output_format} development/ examples/ nautobot/ tasks.py"
    +    run_command(context, command)
    +
    +
     @task
     def serve_docs(context):
         """Runs local instance of mkdocs serve (ctrl-c to stop)."""
    @@ -849,6 +856,7 @@ def tests(context, lint_only=False, keepdb=False):
         flake8(context)
         hadolint(context)
         markdownlint(context)
    +    ruff(context)
         pylint(context)
         check_migrations(context)
         check_schema(context)
    
54abe23331b6

[2.0] Fix unsafe `mark_safe` use in various features (#4832)

https://github.com/nautobot/nautobotGlenn MatthewsNov 21, 2023via ghsa
31 files changed · +542 228
  • changes/4832.changed+1 0 added
    @@ -0,0 +1 @@
    +Changed the `render_jinja2()` API to no longer automatically call `mark_safe()` on the output.
    
  • changes/4832.housekeeping+1 0 added
    @@ -0,0 +1 @@
    +Added `ruff` to invoke tasks and CI.
    
  • changes/4832.security+1 0 added
    @@ -0,0 +1 @@
    +Fixed cross-site-scripting (XSS) potential with maliciously crafted Custom Links, Computed Fields, and Job Buttons (GHSA-cf9f-wmhp-v4pr).
    
  • .github/workflows/ci_pullrequest.yml+17 0 modified
    @@ -31,6 +31,17 @@ jobs:
             uses: "networktocode/gh-action-setup-poetry-environment@v2"
           - name: "Linting: flake8"
             run: "poetry run invoke flake8"
    +  ruff:
    +    runs-on: "ubuntu-20.04"
    +    env:
    +      INVOKE_NAUTOBOT_LOCAL: "True"
    +    steps:
    +      - name: "Check out repository code"
    +        uses: "actions/checkout@v2"
    +      - name: "Setup environment"
    +        uses: "networktocode/gh-action-setup-poetry-environment@v2"
    +      - name: "Linting: ruff"
    +        run: "poetry run invoke ruff --output-format github"
       prettier:
         runs-on: "ubuntu-20.04"
         env:
    @@ -212,6 +223,7 @@ jobs:
           - "flake8"
           - "hadolint"
           - "markdownlint"
    +      - "ruff"
           - "yamllint"
       tests-mysql:
         name: "Unit tests (MySQL and Python 3.11)"
    @@ -262,6 +274,7 @@ jobs:
           - "flake8"
           - "hadolint"
           - "markdownlint"
    +      - "ruff"
           - "yamllint"
       tests-ui:
         runs-on: "ubuntu-20.04"
    @@ -332,6 +345,7 @@ jobs:
           - "flake8"
           - "hadolint"
           - "markdownlint"
    +      - "ruff"
           - "yamllint"
       migration-tests-postgres:
         name: "Run migrations against test dataset (PostgreSQL and Python 3.11)"
    @@ -373,6 +387,7 @@ jobs:
           - "flake8"
           - "hadolint"
           - "markdownlint"
    +      - "ruff"
           - "yamllint"
       integration-test:
         runs-on: "ubuntu-20.04"
    @@ -420,6 +435,7 @@ jobs:
           - "flake8"
           - "hadolint"
           - "markdownlint"
    +      - "ruff"
           - "yamllint"
       changelog:
         if: contains(fromJson('["develop","next","develop-1.6"]'), github.base_ref)
    @@ -445,6 +461,7 @@ jobs:
           - "flake8"
           - "hadolint"
           - "markdownlint"
    +      - "ruff"
           - "yamllint"
         steps:
           - name: "Check out repository code"
    
  • nautobot/core/settings.py+1 1 modified
    @@ -645,7 +645,7 @@
         ),
         "NETWORK_DRIVERS": ConstanceConfigItem(
             default={},
    -        help_text=mark_safe(
    +        help_text=mark_safe(  # noqa: S308
                 "Extend or override default Platform.network_driver translations provided by "
                 '<a href="https://netutils.readthedocs.io/en/latest/user/lib_use_cases_lib_mapper/">netutils</a>. '
                 "Enter a dictionary in JSON format, for example:\n"
    
  • nautobot/core/tables.py+26 31 modified
    @@ -4,7 +4,7 @@
     from django.core.exceptions import FieldDoesNotExist
     from django.db.models.fields.related import RelatedField
     from django.urls import reverse
    -from django.utils.html import escape, format_html
    +from django.utils.html import escape, format_html, format_html_join
     from django.utils.safestring import mark_safe
     from django.utils.text import Truncator
     import django_tables2
    @@ -162,7 +162,7 @@ def __init__(self, *args, **kwargs):
     
         @property
         def header(self):
    -        return mark_safe('<input type="checkbox" class="toggle" title="Toggle all" />')
    +        return mark_safe('<input type="checkbox" class="toggle" title="Toggle all" />')  # noqa: S308
     
     
     class BooleanColumn(django_tables2.Column):
    @@ -259,7 +259,7 @@ def render(self, record, bound_column, value):  # pylint: disable=arguments-diff
                 name = bound_column.name
                 css_class = getattr(record, f"get_{name}_class")()
                 label = getattr(record, f"get_{name}_display")()
    -            return mark_safe(f'<span class="label label-{css_class}">{label}</span>')
    +            return format_html('<span class="label label-{}">{}</span>', css_class, label)
             return self.default
     
     
    @@ -269,7 +269,7 @@ class ColorColumn(django_tables2.Column):
         """
     
         def render(self, value):
    -        return mark_safe(f'<span class="label color-block" style="background-color: #{value}">&nbsp;</span>')
    +        return format_html('<span class="label color-block" style="background-color: #{}">&nbsp;</span>', value)
     
     
     class ColoredLabelColumn(django_tables2.TemplateColumn):
    @@ -306,7 +306,7 @@ def render(self, record, value):  # pylint: disable=arguments-differ
                 url = reverse(self.viewname, kwargs=self.view_kwargs)
                 if self.url_params:
                     url += "?" + "&".join([f"{k}={getattr(record, v)}" for k, v in self.url_params.items()])
    -            return mark_safe(f'<a href="{url}">{value}</a>')
    +            return format_html('<a href="{}">{}</a>', url, value)
             return value
     
     
    @@ -393,20 +393,18 @@ def __init__(self, customfield, *args, **kwargs):
             super().__init__(*args, **kwargs)
     
         def render(self, record, bound_column, value):  # pylint: disable=arguments-differ
    -        template = ""
             if self.customfield.type == choices.CustomFieldTypeChoices.TYPE_BOOLEAN:
                 template = helpers.render_boolean(value)
             elif self.customfield.type == choices.CustomFieldTypeChoices.TYPE_MULTISELECT:
    -            for v in value:
    -                template += format_html('<span class="label label-default">{}</span> ', v)
    +            template = format_html_join(" ", '<span class="label label-default">{}</span>', ((v,) for v in value))
             elif self.customfield.type == choices.CustomFieldTypeChoices.TYPE_SELECT:
                 template = format_html('<span class="label label-default">{}</span>', value)
             elif self.customfield.type == choices.CustomFieldTypeChoices.TYPE_URL:
                 template = format_html('<a href="{}">{}</a>', value, value)
             else:
                 template = escape(value)
     
    -        return mark_safe(template)
    +        return template
     
     
     class RelationshipColumn(django_tables2.Column):
    @@ -435,30 +433,27 @@ def render(self, record, value):  # pylint: disable=arguments-differ
                 else:
                     value = [v for v in value if v.destination_id == record.id]
     
    -        template = ""
             # Handle Symmetric Relationships
             # List `value` could be empty here [] after the filtering from above
             if len(value) < 1:
                 return "—"
    -        else:
    -            # Handle Relationships on the many side.
    -            if self.relationship.has_many(self.peer_side):
    -                v = value[0]
    -                meta = type(v.get_peer(record))._meta
    -                name = meta.verbose_name_plural if len(value) > 1 else meta.verbose_name
    -                template += format_html(
    -                    '<a href="{}?relationship={}&{}_id={}">{} {}</a>',
    -                    reverse("extras:relationshipassociation_list"),
    -                    self.relationship.key,
    -                    self.side,
    -                    record.id,
    -                    len(value),
    -                    name,
    -                )
    -            # Handle Relationships on the one side.
    -            else:
    -                v = value[0]
    -                peer = v.get_peer(record)
    -                template += format_html('<a href="{}">{}</a>', peer.get_absolute_url(), peer)
     
    -        return mark_safe(template)
    +        # Handle Relationships on the many side.
    +        if self.relationship.has_many(self.peer_side):
    +            v = value[0]
    +            meta = type(v.get_peer(record))._meta
    +            name = meta.verbose_name_plural if len(value) > 1 else meta.verbose_name
    +            return format_html(
    +                '<a href="{}?relationship={}&{}_id={}">{} {}</a>',
    +                reverse("extras:relationshipassociation_list"),
    +                self.relationship.key,
    +                self.side,
    +                record.id,
    +                len(value),
    +                name,
    +            )
    +        # Handle Relationships on the one side.
    +        else:
    +            v = value[0]
    +            peer = v.get_peer(record)
    +            return format_html('<a href="{}">{}</a>', peer.get_absolute_url(), peer)
    
  • nautobot/core/templatetags/helpers.py+9 9 modified
    @@ -20,9 +20,9 @@
     from nautobot.core.utils import color, config, data, lookup
     from nautobot.core.utils.navigation import is_route_new_ui_ready
     
    -HTML_TRUE = '<span class="text-success"><i class="mdi mdi-check-bold" title="Yes"></i></span>'
    -HTML_FALSE = '<span class="text-danger"><i class="mdi mdi-close-thick" title="No"></i></span>'
    -HTML_NONE = '<span class="text-muted">&mdash;</span>'
    +HTML_TRUE = mark_safe('<span class="text-success"><i class="mdi mdi-check-bold" title="Yes"></i></span>')  # noqa: S308
    +HTML_FALSE = mark_safe('<span class="text-danger"><i class="mdi mdi-close-thick" title="No"></i></span>')  # noqa: S308
    +HTML_NONE = mark_safe('<span class="text-muted">&mdash;</span>')  # noqa: S308
     
     DEFAULT_SUPPORT_MESSAGE = (
         "If further assistance is required, please join the `#nautobot` channel "
    @@ -99,7 +99,7 @@ def placeholder(value):
         """
         if value:
             return value
    -    return mark_safe(HTML_NONE)
    +    return HTML_NONE
     
     
     @library.filter()
    @@ -123,7 +123,7 @@ def add_html_id(element_str, id_str):
         match = re.match(r"^(.*?<\w+) ?(.*)$", element_str, flags=re.DOTALL)
         if not match:
             return element_str
    -    return mark_safe(match.group(1) + format_html(' id="{}" ', id_str) + match.group(2))
    +    return mark_safe(match.group(1) + format_html(' id="{}" ', id_str) + match.group(2))  # noqa: S308
     
     
     @library.filter()
    @@ -154,10 +154,10 @@ def render_boolean(value):
             '<span class="text-danger"><i class="mdi mdi-close-thick" title="No"></i></span>'
         """
         if value is None:
    -        return mark_safe(HTML_NONE)
    +        return HTML_NONE
         if bool(value):
    -        return mark_safe(HTML_TRUE)
    -    return mark_safe(HTML_FALSE)
    +        return HTML_TRUE
    +    return HTML_FALSE
     
     
     @library.filter()
    @@ -180,7 +180,7 @@ def render_markdown(value):
         # Render Markdown
         html = markdown(value, extensions=["fenced_code", "tables"])
     
    -    return mark_safe(html)
    +    return mark_safe(html)  # noqa: S308
     
     
     @library.filter()
    
  • nautobot/core/utils/data.py+5 1 modified
    @@ -100,7 +100,11 @@ def render_jinja2(template_code, context):
         """
         rendering_engine = engines["jinja"]
         template = rendering_engine.from_string(template_code)
    -    return template.render(context=context)
    +    # For reasons unknown to me, django-jinja2 `template.render()` implicitly calls `mark_safe()` on the rendered text.
    +    # This is a security risk in general, especially so in our case because we're often using this function to render
    +    # a user-provided template and don't want to open ourselves up to script injection or similar issues.
    +    # There's no `mark_unsafe()` function, but concatenating a SafeString to an ordinary string (even "") suffices.
    +    return "" + template.render(context=context)
     
     
     def shallow_compare_dict(source_dict, destination_dict, exclude=None):
    
  • nautobot/core/views/generic.py+6 7 modified
    @@ -16,9 +16,8 @@
     from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput
     from django.http import HttpResponse, JsonResponse
     from django.shortcuts import get_object_or_404, redirect, render
    -from django.utils.html import escape
    +from django.utils.html import format_html
     from django.utils.http import is_safe_url
    -from django.utils.safestring import mark_safe
     from django.views.generic import View
     from django_tables2 import RequestConfig
     from rest_framework.exceptions import ParseError
    @@ -209,7 +208,7 @@ def get(self, request):
                 if not filterset.is_valid():
                     messages.error(
                         request,
    -                    mark_safe(f"Invalid filters were specified: {filterset.errors}"),
    +                    format_html("Invalid filters were specified: {}", filterset.errors),
                     )
                     self.queryset = self.queryset.none()
     
    @@ -397,10 +396,10 @@ def successful_post(self, request, obj, created, logger):
             msg = f"{verb} {self.queryset.model._meta.verbose_name}"
             logger.info(f"{msg} {obj} (PK: {obj.pk})")
             if hasattr(obj, "get_absolute_url"):
    -            msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
    +            msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
             else:
    -            msg = f"{msg} {escape(obj)}"
    -        messages.success(request, mark_safe(msg))
    +            msg = format_html("{} {}", msg, obj)
    +        messages.success(request, msg)
     
         def post(self, request, *args, **kwargs):
             logger = logging.getLogger(__name__ + ".ObjectEditView")
    @@ -752,7 +751,7 @@ def post(self, request):
                     logger.info(f"Import object {obj} (PK: {obj.pk})")
                     messages.success(
                         request,
    -                    mark_safe(f'Imported object: <a href="{obj.get_absolute_url()}">{obj}</a>'),
    +                    format_html('Imported object: <a href="{}">{}</a>', obj.get_absolute_url(), obj),
                     )
     
                     if "_addanother" in request.POST:
    
  • nautobot/core/views/mixins.py+5 6 modified
    @@ -19,8 +19,7 @@
     from django.urls import reverse
     from django.urls.exceptions import NoReverseMatch
     from django.utils.http import is_safe_url
    -from django.utils.html import escape
    -from django.utils.safestring import mark_safe
    +from django.utils.html import format_html
     from django.views.generic.edit import FormView
     
     from rest_framework import mixins, exceptions
    @@ -622,7 +621,7 @@ def filter_queryset(self, queryset):
                 if not self.filterset.is_valid():
                     messages.error(
                         self.request,
    -                    mark_safe(f"Invalid filters were specified: {self.filterset.errors}"),
    +                    format_html("Invalid filters were specified: {}", self.filterset.errors),
                     )
                     queryset = queryset.none()
             return queryset
    @@ -748,10 +747,10 @@ def _process_create_or_update_form(self, form):
                 msg = f'{"Created" if object_created else "Modified"} {queryset.model._meta.verbose_name}'
                 self.logger.info(f"{msg} {obj} (PK: {obj.pk})")
                 if hasattr(obj, "get_absolute_url"):
    -                msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
    +                msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
                 else:
    -                msg = f"{msg} { escape(obj)}"
    -            messages.success(request, mark_safe(msg))
    +                msg = format_html("{} {}", msg, obj)
    +            messages.success(request, msg)
                 if "_addanother" in request.POST:
                     # If the object has clone_fields, pre-populate a new instance of the form
                     if hasattr(obj, "clone_fields"):
    
  • nautobot/core/views/utils.py+23 24 modified
    @@ -3,7 +3,7 @@
     from django.contrib import messages
     from django.core.exceptions import FieldError
     from django.db.models import ForeignKey
    -from django.utils.html import escape
    +from django.utils.html import format_html, format_html_join
     from django.utils.safestring import mark_safe
     
     from rest_framework import serializers
    @@ -114,13 +114,13 @@ def get_csv_form_fields_from_serializer_class(serializer_class):
                         "help_text": cf_form_field.help_text,
                     }
                     if cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
    -                    field_info["format"] = mark_safe("<code>true</code> or <code>false</code>")
    +                    field_info["format"] = mark_safe("<code>true</code> or <code>false</code>")  # noqa: S308
                     elif cf.type == CustomFieldTypeChoices.TYPE_DATE:
    -                    field_info["format"] = mark_safe("<code>YYYY-MM-DD</code>")
    +                    field_info["format"] = mark_safe("<code>YYYY-MM-DD</code>")  # noqa: S308
                     elif cf.type == CustomFieldTypeChoices.TYPE_SELECT:
                         field_info["choices"] = {cfc.value: cfc.value for cfc in cf.custom_field_choices.all()}
                     elif cf.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
    -                    field_info["format"] = mark_safe('<code>"value,value"</code>')
    +                    field_info["format"] = mark_safe('<code>"value,value"</code>')  # noqa: S308
                         field_info["choices"] = {cfc.value: cfc.value for cfc in cf.custom_field_choices.all()}
                     fields.append(field_info)
                 continue
    @@ -132,27 +132,27 @@ def get_csv_form_fields_from_serializer_class(serializer_class):
                 "help_text": field.help_text,
             }
             if isinstance(field, serializers.BooleanField):
    -            field_info["format"] = mark_safe("<code>true</code> or <code>false</code>")
    +            field_info["format"] = mark_safe("<code>true</code> or <code>false</code>")  # noqa: S308
             elif isinstance(field, serializers.DateField):
    -            field_info["format"] = mark_safe("<code>YYYY-MM-DD</code>")
    +            field_info["format"] = mark_safe("<code>YYYY-MM-DD</code>")  # noqa: S308
             elif isinstance(field, TimeZoneSerializerField):
    -            field_info["format"] = mark_safe(
    +            field_info["format"] = mark_safe(  # noqa: S308
                     '<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">available options</a>'
                 )
             elif isinstance(field, serializers.ManyRelatedField):
                 if field.field_name == "tags":
    -                field_info["format"] = mark_safe('<code>"name,name"</code> or <code>"UUID,UUID"</code>')
    +                field_info["format"] = mark_safe('<code>"name,name"</code> or <code>"UUID,UUID"</code>')  # noqa: S308
                 elif isinstance(field.child_relation, ContentTypeField):
    -                field_info["format"] = mark_safe('<code>"app_label.model,app_label.model"</code>')
    +                field_info["format"] = mark_safe('<code>"app_label.model,app_label.model"</code>')  # noqa: S308
                 else:
    -                field_info["format"] = mark_safe('<code>"UUID,UUID"</code>')
    +                field_info["format"] = mark_safe('<code>"UUID,UUID"</code>')  # noqa: S308
             elif isinstance(field, serializers.RelatedField):
                 if isinstance(field, ContentTypeField):
    -                field_info["format"] = mark_safe("<code>app_label.model</code>")
    +                field_info["format"] = mark_safe("<code>app_label.model</code>")  # noqa: S308
                 else:
    -                field_info["format"] = mark_safe("<code>UUID</code>")
    +                field_info["format"] = mark_safe("<code>UUID</code>")  # noqa: S308
             elif isinstance(field, (serializers.ListField, serializers.MultipleChoiceField)):
    -            field_info["format"] = mark_safe('<code>"value,value"</code>')
    +            field_info["format"] = mark_safe('<code>"value,value"</code>')  # noqa: S308
             elif isinstance(field, (serializers.DictField, serializers.JSONField)):
                 pass  # Not trivial to specify a format as it could be a JSON dict or a comma-separated string
     
    @@ -173,21 +173,20 @@ def handle_protectederror(obj_list, request, e):
         """
         protected_objects = list(e.protected_objects)
         protected_count = len(protected_objects) if len(protected_objects) <= 50 else "More than 50"
    -    err_message = (
    -        f"Unable to delete <strong>{', '.join(str(obj) for obj in obj_list)}</strong>. "
    -        f"{protected_count} dependent objects were found: "
    +    err_message = format_html(
    +        "Unable to delete <strong>{}</strong>. {} dependent objects were found: ",
    +        ", ".join(str(obj) for obj in obj_list),
    +        protected_count,
         )
     
         # Append dependent objects to error message
    -    dependent_objects = []
    -    for dependent in protected_objects[:50]:
    -        if hasattr(dependent, "get_absolute_url"):
    -            dependent_objects.append(f'<a href="{dependent.get_absolute_url()}">{escape(dependent)}</a>')
    -        else:
    -            dependent_objects.append(str(dependent))
    -    err_message += ", ".join(dependent_objects)
    +    err_message += format_html_join(
    +        ", ",
    +        '<a href="{}">{}</a>',
    +        ((dependent.get_absolute_url(), dependent) for dependent in protected_objects[:50]),
    +    )
     
    -    messages.error(request, mark_safe(err_message))
    +    messages.error(request, err_message)
     
     
     def prepare_cloned_fields(instance):
    
  • nautobot/dcim/models/devices.py+7 6 modified
    @@ -8,10 +8,9 @@
     from django.core.validators import MaxValueValidator, MinValueValidator
     from django.db import models
     from django.db.models import F, ProtectedError, Q
    -from django.utils.functional import cached_property
     from django.urls import reverse
    -from django.utils.functional import classproperty
    -from django.utils.safestring import mark_safe
    +from django.utils.functional import cached_property, classproperty
    +from django.utils.html import format_html
     
     from nautobot.core.models import BaseManager
     from nautobot.core.models.fields import NaturalOrderingField
    @@ -257,9 +256,11 @@ def clean(self):
                     url = f"{reverse('dcim:device_list')}?manufacturer={self.manufacturer_id}&device_type={self.pk}"
                     raise ValidationError(
                         {
    -                        "u_height": mark_safe(
    -                            f'Unable to set 0U height: Found <a href="{url}">{racked_instance_count} instances</a> already '
    -                            f"mounted within racks."
    +                        "u_height": format_html(
    +                            "Unable to set 0U height: "
    +                            'Found <a href="{}">{} instances</a> already mounted within racks.',
    +                            url,
    +                            racked_instance_count,
                             )
                         }
                     )
    
  • nautobot/dcim/views.py+5 6 modified
    @@ -13,8 +13,7 @@
     )
     from django.shortcuts import get_object_or_404, redirect, render
     from django.utils.functional import cached_property
    -from django.utils.html import escape
    -from django.utils.safestring import mark_safe
    +from django.utils.html import format_html
     from django.views.generic import View
     from django_tables2 import RequestConfig
     
    @@ -2634,8 +2633,8 @@ def post(self, request, pk):
     
                 if membership_form.is_valid():
                     membership_form.save()
    -                msg = f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
    -                messages.success(request, mark_safe(msg))
    +                msg = format_html('Added member <a href="{}">{}</a>', device.get_absolute_url(), device)
    +                messages.success(request, msg)
     
                     if "_addanother" in request.POST:
                         return redirect(request.get_full_path())
    @@ -2684,8 +2683,8 @@ def post(self, request, pk):
             # Protect master device from being removed
             virtual_chassis = VirtualChassis.objects.filter(master=device).first()
             if virtual_chassis is not None:
    -            msg = f"Unable to remove master device {escape(device)} from the virtual chassis."
    -            messages.error(request, mark_safe(msg))
    +            msg = format_html("Unable to remove master device {} from the virtual chassis.", device)
    +            messages.error(request, msg)
                 return redirect(device.get_absolute_url())
     
             if form.is_valid():
    
  • nautobot/extras/models/customfields.py+2 2 modified
    @@ -10,7 +10,7 @@
     from django.core.serializers.json import DjangoJSONEncoder
     from django.core.validators import RegexValidator, ValidationError
     from django.forms.widgets import TextInput
    -from django.utils.safestring import mark_safe
    +from django.utils.html import format_html
     
     from nautobot.core.forms import (
         add_blank_choice,
    @@ -517,7 +517,7 @@ def to_form_field(
                     field.validators = [
                         RegexValidator(
                             regex=self.validation_regex,
    -                        message=mark_safe(f"Values must match this regex: <code>{self.validation_regex}</code>"),
    +                        message=format_html("Values must match this regex: <code>{}</code>", self.validation_regex),
                         )
                     ]
     
    
  • nautobot/extras/models/models.py+5 2 modified
    @@ -330,12 +330,15 @@ class CustomLink(BaseModel, ChangeLoggedModel, NotesMixin):
         name = models.CharField(max_length=100, unique=True)
         text = models.CharField(
             max_length=500,
    -        help_text="Jinja2 template code for link text. Reference the object as <code>{{ obj }}</code> such as <code>{{ obj.platform.name }}</code>. Links which render as empty text will not be displayed.",
    +        help_text="Jinja2 template code for link text. "
    +        "Reference the object as <code>{{ obj }}</code> such as <code>{{ obj.platform.name }}</code>. "
    +        "Links which render as empty text will not be displayed.",
         )
         target_url = models.CharField(
             max_length=500,
             verbose_name="URL",
    -        help_text="Jinja2 template code for link URL. Reference the object as <code>{{ obj }}</code> such as <code>{{ obj.platform.name }}</code>.",
    +        help_text="Jinja2 template code for link URL. "
    +        "Reference the object as <code>{{ obj }}</code> such as <code>{{ obj.platform.name }}</code>.",
         )
         weight = models.PositiveSmallIntegerField(default=100)
         group_name = models.CharField(
    
  • nautobot/extras/models/relationships.py+13 8 modified
    @@ -9,7 +9,7 @@
     from django.db.models import Q
     from django.urls import reverse
     from django.urls.exceptions import NoReverseMatch
    -from django.utils.safestring import mark_safe
    +from django.utils.html import format_html
     
     from nautobot.core.forms import (
         DynamicModelChoiceField,
    @@ -19,6 +19,7 @@
     from nautobot.core.models import BaseManager, BaseModel
     from nautobot.core.models.fields import AutoSlugField, slugify_dashes_to_underscores
     from nautobot.core.models.querysets import RestrictedQuerySet
    +from nautobot.core.templatetags.helpers import bettertitle
     from nautobot.core.utils.lookup import get_filterset_for_model, get_route_for_model
     from nautobot.extras.choices import RelationshipTypeChoices, RelationshipRequiredSideChoices, RelationshipSideChoices
     from nautobot.extras.utils import FeatureQuery, check_if_key_is_graphql_safe, extras_features
    @@ -275,9 +276,10 @@ def required_related_objects_errors(
                     if output_for == "ui":
                         try:
                             add_url = reverse(get_route_for_model(required_model_class, "add"))
    -                        hint = (
    -                            f"<a target='_blank' href='{add_url}'>Click here</a> to create "
    -                            f"a {required_model_meta.verbose_name}."
    +                        hint = format_html(
    +                            '<a target="_blank" href="{}">Click here</a> to create a {}.',
    +                            add_url,
    +                            required_model_meta.verbose_name,
                             )
                         except NoReverseMatch:
                             pass
    @@ -289,11 +291,14 @@ def required_related_objects_errors(
                         except NoReverseMatch:
                             pass
     
    -                error_message = mark_safe(
    -                    f"{name_plural[0].upper()}{name_plural[1:]} require "
    -                    f"{num_required_verbose} {required_model_meta.verbose_name}, but no "
    -                    f"{required_model_meta.verbose_name_plural} exist yet. {hint}"
    +                error_message = format_html(
    +                    "{} require {} {}, but no {} exist yet. ",
    +                    bettertitle(name_plural),
    +                    num_required_verbose,
    +                    required_model_meta.verbose_name,
    +                    required_model_meta.verbose_name_plural,
                     )
    +                error_message += hint
                     field_errors[field_key].append(error_message)
     
                 if initial_data is not None:
    
  • nautobot/extras/tables.py+1 2 modified
    @@ -1,7 +1,6 @@
     import django_tables2 as tables
     from django.conf import settings
     from django.utils.html import format_html
    -from django.utils.safestring import mark_safe
     from django_tables2.utils import Accessor
     from jsonschema.exceptions import ValidationError as JSONSchemaValidationError
     
    @@ -236,7 +235,7 @@ class Meta(BaseTable.Meta):
     
         def render_description(self, record):
             if record.description:
    -            return mark_safe(render_markdown(record.description))
    +            return render_markdown(record.description)
             return self.default
     
     
    
  • nautobot/extras/templatetags/computed_fields.py+6 13 modified
    @@ -1,7 +1,6 @@
     from django import template
     from django.contrib.contenttypes.models import ContentType
    -from django.utils.html import escape
    -from django.utils.safestring import mark_safe
    +from django.utils.html import format_html_join
     
     from nautobot.extras.models import ComputedField
     
    @@ -27,14 +26,8 @@ def computed_fields(context, obj, advanced_ui=None):
         if not computed_fields:
             return ""
     
    -    template_code = ""
    -
    -    for label, value in fields.items():
    -        escaped_label = escape(label)
    -        template_code += f"""
    -            <tr>
    -                <td><span title="{escaped_label}">{escaped_label}</span></td>
    -                <td>{escape(value)}</td>
    -            <tr>
    -            """
    -    return mark_safe(template_code)
    +    return format_html_join(
    +        "\n",
    +        '<tr><td><span title="{}">{}</span></td><td>{}</td></tr>',
    +        ((label, label, value) for label, value in fields.items()),
    +    )
    
  • nautobot/extras/templatetags/custom_links.py+19 12 modified
    @@ -2,6 +2,7 @@
     
     from django import template
     from django.contrib.contenttypes.models import ContentType
    +from django.utils.html import format_html
     from django.utils.safestring import mark_safe
     
     from nautobot.core.utils.data import render_jinja2
    @@ -40,7 +41,7 @@ def custom_links(context, obj):
             "user": context["user"],  # django.contrib.auth.context_processors.auth
             "perms": context["perms"],  # django.contrib.auth.context_processors.auth
         }
    -    template_code = ""
    +    template_code = mark_safe("")  # noqa: S308
         group_names = OrderedDict()
     
         for cl in links:
    @@ -57,31 +58,37 @@ def custom_links(context, obj):
                     if text_rendered:
                         link_rendered = render_jinja2(cl.target_url, link_context)
                         link_target = ' target="_blank"' if cl.new_window else ""
    -                    template_code += LINK_BUTTON.format(link_rendered, link_target, cl.button_class, text_rendered)
    +                    template_code += format_html(
    +                        LINK_BUTTON, link_rendered, link_target, cl.button_class, text_rendered
    +                    )
                 except Exception as e:
    -                template_code += (
    -                    f'<a class="btn btn-sm btn-default" disabled="disabled" title="{e}">'
    -                    f'<i class="mdi mdi-alert"></i> {cl.name}</a>\n'
    +                template_code += format_html(
    +                    '<a class="btn btn-sm btn-default" disabled="disabled" title="{}">'
    +                    '<i class="mdi mdi-alert"></i> {}</a>\n',
    +                    e,
    +                    cl.name,
                     )
     
         # Add grouped links to template
         for group, links in group_names.items():
    -        links_rendered = []
    +        links_rendered = mark_safe("")  # noqa: S308
     
             for cl in links:
                 try:
                     text_rendered = render_jinja2(cl.text, link_context)
                     if text_rendered:
                         link_target = ' target="_blank"' if cl.new_window else ""
                         link_rendered = render_jinja2(cl.target_url, link_context)
    -                    links_rendered.append(GROUP_LINK.format(link_rendered, link_target, text_rendered))
    +                    links_rendered += format_html(GROUP_LINK, link_rendered, link_target, text_rendered)
                 except Exception as e:
    -                links_rendered.append(
    -                    f'<li><a disabled="disabled" title="{e}"><span class="text-muted">'
    -                    f'<i class="mdi mdi-alert"></i> {cl.name}</span></a></li>'
    +                links_rendered += format_html(
    +                    '<li><a disabled="disabled" title="{}"><span class="text-muted">'
    +                    '<i class="mdi mdi-alert"></i> {}</span></a></li>',
    +                    e,
    +                    cl.name,
                     )
     
             if links_rendered:
    -            template_code += GROUP_BUTTON.format(links[0].button_class, group, "".join(links_rendered))
    +            template_code += format_html(GROUP_BUTTON, links[0].button_class, group, links_rendered)
     
    -    return mark_safe(template_code)
    +    return template_code
    
  • nautobot/extras/templatetags/job_buttons.py+34 19 modified
    @@ -3,6 +3,7 @@
     from django import template
     from django.contrib.contenttypes.models import ContentType
     from django.urls import reverse
    +from django.utils.html import format_html
     from django.utils.safestring import mark_safe
     
     from nautobot.extras.models import JobButton
    @@ -87,10 +88,11 @@ def job_buttons(context, obj):
             "user": context["user"],  # django.contrib.auth.context_processors.auth
             "perms": context["perms"],  # django.contrib.auth.context_processors.auth
         }
    -    buttons_html = forms_html = ""
    +    buttons_html = forms_html = mark_safe("")  # noqa: S308
         group_names = OrderedDict()
     
    -    hidden_inputs = HIDDEN_INPUTS.format(
    +    hidden_inputs = format_html(
    +        HIDDEN_INPUTS,
             csrf_token=context["csrf_token"],
             object_pk=obj.pk,
             object_model_name=f"{content_type.app_label}.{content_type.model}",
    @@ -121,22 +123,24 @@ def job_buttons(context, obj):
                     if text_rendered:
                         template_args["button_text"] = text_rendered
                         if jb.confirmation:
    -                        buttons_html += CONFIRM_BUTTON.format(**template_args)
    -                        forms_html += CONFIRM_MODAL.format(**template_args)
    +                        buttons_html += format_html(CONFIRM_BUTTON, **template_args)
    +                        forms_html += format_html(CONFIRM_MODAL, **template_args)
                         else:
    -                        buttons_html += NO_CONFIRM_BUTTON.format(**template_args)
    -                        forms_html += NO_CONFIRM_FORM.format(**template_args)
    +                        buttons_html += format_html(NO_CONFIRM_BUTTON, **template_args)
    +                        forms_html += format_html(NO_CONFIRM_FORM, **template_args)
                 except Exception as e:
    -                buttons_html += (
    -                    f'<a class="btn btn-sm btn-default" disabled="disabled" title="{e}">'
    -                    f'<i class="mdi mdi-alert"></i> {jb.name}</a>\n'
    +                buttons_html += format_html(
    +                    '<a class="btn btn-sm btn-default" disabled="disabled" title="{}">'
    +                    '<i class="mdi mdi-alert"></i> {}</a>\n',
    +                    e,
    +                    jb.name,
                     )
     
         # Add grouped buttons to template
         for group_name, buttons in group_names.items():
             group_button_class = buttons[0].button_class
     
    -        buttons_rendered = ""
    +        buttons_rendered = mark_safe("")  # noqa: S308
     
             for jb in buttons:
                 template_args = {
    @@ -154,23 +158,34 @@ def job_buttons(context, obj):
                     if text_rendered:
                         template_args["button_text"] = text_rendered
                         if jb.confirmation:
    -                        buttons_rendered += "<li>" + CONFIRM_BUTTON.format(**template_args) + "</li>"
    -                        forms_html += CONFIRM_MODAL.format(**template_args)
    +                        buttons_rendered += (
    +                            mark_safe("<li>")  # noqa: S308
    +                            + format_html(CONFIRM_BUTTON, **template_args)
    +                            + mark_safe("</li>")  # noqa: S308
    +                        )
    +                        forms_html += format_html(CONFIRM_MODAL, **template_args)
                         else:
    -                        buttons_rendered += "<li>" + NO_CONFIRM_BUTTON.format(**template_args) + "</li>"
    -                        forms_html += NO_CONFIRM_FORM.format(**template_args)
    +                        buttons_rendered += (
    +                            mark_safe("<li>")  # noqa: S308
    +                            + format_html(NO_CONFIRM_BUTTON, **template_args)
    +                            + mark_safe("</li>")  # noqa: S308
    +                        )
    +                        forms_html += format_html(NO_CONFIRM_FORM, **template_args)
                 except Exception as e:
    -                buttons_rendered += (
    -                    f'<li><a disabled="disabled" title="{e}"><span class="text-muted">'
    -                    f'<i class="mdi mdi-alert"></i> {jb.name}</span></a></li>'
    +                buttons_rendered += format_html(
    +                    '<li><a disabled="disabled" title="{}"><span class="text-muted">'
    +                    '<i class="mdi mdi-alert"></i> {}</span></a></li>',
    +                    e,
    +                    jb.name,
                     )
     
             if buttons_rendered:
    -            buttons_html += GROUP_DROPDOWN.format(
    +            buttons_html += format_html(
    +                GROUP_DROPDOWN,
                     group_button_class=group_button_class,
                     group_name=group_name,
                     grouped_buttons=buttons_rendered,
                 )
     
         # We want all of the buttons first and then any modals and forms so the buttons render properly
    -    return mark_safe(buttons_html + forms_html)
    +    return buttons_html + forms_html
    
  • nautobot/extras/templatetags/plugins.py+1 1 modified
    @@ -52,7 +52,7 @@ def _get_registered_content(obj, method, template_context, return_html=True):
         if not return_html:
             return objects
     
    -    return mark_safe(html)
    +    return mark_safe(html)  # noqa: S308
     
     
     @register.simple_tag(takes_context=True)
    
  • nautobot/extras/tests/test_customfields.py+1 1 modified
    @@ -2119,7 +2119,7 @@ def test_custom_field_table_render(self):
                 "url_field": '<a href="http://example.com/2">http://example.com/2</a>',
                 "choice_field": '<span class="label label-default">Bar</span>',
                 "multi_choice_field": (
    -                '<span class="label label-default">Bar</span> <span class="label label-default">Baz</span> '
    +                '<span class="label label-default">Bar</span> <span class="label label-default">Baz</span>'
                 ),
             }
     
    
  • nautobot/extras/tests/test_relationships.py+2 2 modified
    @@ -1206,13 +1206,13 @@ def required_relationships_test(self, interact_with="ui"):
                     ],
                     "expected_errors": {
                         "api": {
    -                        "objects_nonexistent": "Circuit types require a platform, but no platforms exist yet. "
    +                        "objects_nonexistent": "Circuit Types require a platform, but no platforms exist yet. "
                             "Create a platform by posting to /api/dcim/platforms/",
                             "objects_not_specified": 'You need to specify ["relationships"]["circuittype_platform_o2o"]'
                             '["destination"]["objects"].',
                         },
                         "ui": {
    -                        "objects_nonexistent": "Circuit types require a platform, but no platforms exist yet.",
    +                        "objects_nonexistent": "Circuit Types require a platform, but no platforms exist yet.",
                             "objects_not_specified": "You need to select a platform.",
                         },
                     },
    
  • nautobot/extras/tests/test_views.py+182 2 modified
    @@ -8,6 +8,7 @@
     from django.test import override_settings
     from django.urls import reverse
     from django.utils import timezone
    +from django.utils.html import format_html
     from unittest import mock
     
     from nautobot.core.choices import ColorChoices
    @@ -25,6 +26,7 @@
     )
     from nautobot.extras.constants import HTTP_CONTENT_TYPE_JSON
     from nautobot.extras.models import (
    +    ComputedField,
         ConfigContext,
         ConfigContextSchema,
         CustomField,
    @@ -48,7 +50,6 @@
         Status,
         Tag,
         Webhook,
    -    ComputedField,
     )
     from nautobot.extras.tests.constants import BIG_GRAPHQL_DEVICE_QUERY
     from nautobot.extras.tests.test_relationships import RequiredRelationshipTestMixin
    @@ -130,6 +131,63 @@ def setUpTestData(cls):
             cls.slug_test_object = "Computed Field Five"
     
     
    +class ComputedFieldRenderingTestCase(TestCase):
    +    """Tests for the inclusion of ComputedFields, distinct from tests of the ComputedField views themselves."""
    +
    +    user_permissions = ["dcim.view_locationtype"]
    +
    +    def setUp(self):
    +        super().setUp()
    +        self.computedfield = ComputedField(
    +            content_type=ContentType.objects.get_for_model(LocationType),
    +            key="test",
    +            label="Computed Field",
    +            template="FOO {{ obj.name }} BAR",
    +            fallback_value="Fallback Value",
    +            weight=100,
    +        )
    +        self.computedfield.validated_save()
    +        self.location_type = LocationType.objects.get(name="Campus")
    +
    +    def test_view_object_with_computed_field(self):
    +        """Ensure that the computed field template is rendered."""
    +        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertIn(f"FOO {self.location_type.name} BAR", content, content)
    +
    +    def test_view_object_with_computed_field_fallback_value(self):
    +        """Ensure that the fallback_value is rendered if the template fails to render."""
    +        # Make the template invalid to demonstrate the fallback value
    +        self.computedfield.template = "FOO {{ obj."
    +        self.computedfield.validated_save()
    +        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertIn("Fallback Value", content, content)
    +
    +    def test_view_object_with_computed_field_unsafe_template(self):
    +        """Ensure that computed field templates can't be used as an XSS vector."""
    +        self.computedfield.template = '<script>alert("Hello world!"</script>'
    +        self.computedfield.validated_save()
    +        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertNotIn("<script>alert", content, content)
    +        self.assertIn("&lt;script&gt;alert", content, content)
    +
    +    def test_view_object_with_computed_field_unsafe_fallback_value(self):
    +        """Ensure that computed field fallback values can't be used as an XSS vector."""
    +        self.computedfield.template = "FOO {{ obj."
    +        self.computedfield.fallback_value = '<script>alert("Hello world!"</script>'
    +        self.computedfield.validated_save()
    +        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertNotIn("<script>alert", content, content)
    +        self.assertIn("&lt;script&gt;alert", content, content)
    +
    +
     # TODO: Change base class to PrimaryObjectViewTestCase
     # Blocked by absence of standard create/edit, bulk create views
     class ConfigContextTestCase(
    @@ -432,7 +490,9 @@ def test_create_object_with_constrained_permission(self):
             super().test_create_object_with_constrained_permission()
     
     
    -class CustomLinkTest(TestCase):
    +class CustomLinkRenderingTestCase(TestCase):
    +    """Tests for the inclusion of CustomLinks, distinct from tests of the CustomLink views themselves."""
    +
         user_permissions = ["dcim.view_location"]
     
         def test_view_object_with_custom_link(self):
    @@ -454,6 +514,71 @@ def test_view_object_with_custom_link(self):
             content = extract_page_body(response.content.decode(response.charset))
             self.assertIn(f"FOO {location.name} BAR", content, content)
     
    +    def test_view_object_with_unsafe_custom_link_text(self):
    +        """Ensure that custom links can't be used as a vector for injecting scripts or breaking HTML."""
    +        customlink = CustomLink(
    +            content_type=ContentType.objects.get_for_model(Location),
    +            name="Test",
    +            text='<script>alert("Hello world!")</script>',
    +            target_url="http://example.com/?location=None",
    +            new_window=False,
    +        )
    +        customlink.validated_save()
    +        location_type = LocationType.objects.get(name="Campus")
    +        status = Status.objects.get_for_model(Location).first()
    +        location = Location(name="Test Location", location_type=location_type, status=status)
    +        location.save()
    +
    +        response = self.client.get(location.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertNotIn("<script>alert", content, content)
    +        self.assertIn("&lt;script&gt;alert", content, content)
    +        self.assertIn(format_html('<a href="{}"', customlink.target_url), content, content)
    +
    +    def test_view_object_with_unsafe_custom_link_url(self):
    +        """Ensure that custom links can't be used as a vector for injecting scripts or breaking HTML."""
    +        customlink = CustomLink(
    +            content_type=ContentType.objects.get_for_model(Location),
    +            name="Test",
    +            text="Hello",
    +            target_url='"><script>alert("Hello world!")</script><a href="',
    +            new_window=False,
    +        )
    +        customlink.validated_save()
    +        location_type = LocationType.objects.get(name="Campus")
    +        status = Status.objects.get_for_model(Location).first()
    +        location = Location(name="Test Location", location_type=location_type, status=status)
    +        location.save()
    +
    +        response = self.client.get(location.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertNotIn("<script>alert", content, content)
    +        self.assertIn("&lt;script&gt;alert", content, content)
    +        self.assertIn(format_html('<a href="{}"', customlink.target_url), content, content)
    +
    +    def test_view_object_with_unsafe_custom_link_name(self):
    +        """Ensure that custom links can't be used as a vector for injecting scripts or breaking HTML."""
    +        customlink = CustomLink(
    +            content_type=ContentType.objects.get_for_model(Location),
    +            name='<script>alert("Hello World")</script>',
    +            text="Hello",
    +            target_url="http://example.com/?location={{ obj.name ",  # intentionally bad jinja2 to trigger error case
    +            new_window=False,
    +        )
    +        customlink.validated_save()
    +        location_type = LocationType.objects.get(name="Campus")
    +        status = Status.objects.get_for_model(Location).first()
    +        location = Location(name="Test Location", location_type=location_type, status=status)
    +        location.save()
    +
    +        response = self.client.get(location.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertNotIn("<script>alert", content, content)
    +        self.assertIn("&lt;script&gt;alert", content, content)
    +
     
     class DynamicGroupTestCase(
         ViewTestCases.CreateObjectViewTestCase,
    @@ -1863,6 +1988,61 @@ def setUpTestData(cls):
             }
     
     
    +class JobButtonRenderingTestCase(TestCase):
    +    """Tests for the rendering of JobButtons, distinct from tests of the JobButton views themselves."""
    +
    +    user_permissions = ["dcim.view_locationtype"]
    +
    +    def setUp(self):
    +        super().setUp()
    +        self.job_button = JobButton(
    +            name="JobButton",
    +            text="JobButton {{ obj.name }}",
    +            job=Job.objects.get(job_class_name="TestJobButtonReceiverSimple"),
    +            confirmation=False,
    +        )
    +        self.job_button.validated_save()
    +        self.job_button.content_types.add(ContentType.objects.get_for_model(LocationType))
    +        self.location_type = LocationType.objects.get(name="Campus")
    +
    +    def test_view_object_with_job_button(self):
    +        """Ensure that the job button is rendered."""
    +        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertIn(f"JobButton {self.location_type.name}", content, content)
    +
    +    def test_view_object_with_unsafe_text(self):
    +        """Ensure that JobButton text can't be used as a vector for XSS."""
    +        self.job_button.text = '<script>alert("Hello world!")</script>'
    +        self.job_button.validated_save()
    +        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertNotIn("<script>alert", content, content)
    +        self.assertIn("&lt;script&gt;alert", content, content)
    +
    +        # Make sure grouped rendering is safe too
    +        self.job_button.group = '<script>alert("Goodbye")</script>'
    +        self.job_button.validated_save()
    +        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertNotIn("<script>alert", content, content)
    +        self.assertIn("&lt;script&gt;alert", content, content)
    +
    +    def test_view_object_with_unsafe_name(self):
    +        """Ensure that JobButton names can't be used as a vector for XSS."""
    +        self.job_button.text = "JobButton {{ obj"
    +        self.job_button.name = '<script>alert("Yo")</script>'
    +        self.job_button.validated_save()
    +        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
    +        self.assertEqual(response.status_code, 200)
    +        content = extract_page_body(response.content.decode(response.charset))
    +        self.assertNotIn("<script>alert", content, content)
    +        self.assertIn("&lt;script&gt;alert", content, content)
    +
    +
     # TODO: Convert to StandardTestCases.Views
     class ObjectChangeTestCase(TestCase):
         user_permissions = ("extras.view_objectchange",)
    
  • nautobot/extras/views.py+12 13 modified
    @@ -13,9 +13,8 @@
     from django.template.loader import TemplateDoesNotExist, get_template
     from django.urls import reverse
     from django.utils import timezone
    -from django.utils.html import escape
    +from django.utils.html import format_html
     from django.utils.http import is_safe_url
    -from django.utils.safestring import mark_safe
     from django.views.generic import View
     from django_tables2 import RequestConfig
     from jsonschema.validators import Draft7Validator
    @@ -414,10 +413,10 @@ def post(self, request, *args, **kwargs):
                     msg = f"{verb} {self.queryset.model._meta.verbose_name}"
                     logger.info(f"{msg} {obj} (PK: {obj.pk})")
                     if hasattr(obj, "get_absolute_url"):
    -                    msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
    +                    msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
                     else:
    -                    msg = f"{msg} {escape(obj)}"
    -                messages.success(request, mark_safe(msg))
    +                    msg = format_html("{} {}", msg, obj)
    +                messages.success(request, msg)
     
                     if "_addanother" in request.POST:
                         # If the object has clone_fields, pre-populate a new instance of the form
    @@ -652,10 +651,10 @@ def post(self, request, *args, **kwargs):
                     msg = f"{verb} {self.queryset.model._meta.verbose_name}"
                     logger.info(f"{msg} {obj} (PK: {obj.pk})")
                     if hasattr(obj, "get_absolute_url"):
    -                    msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
    +                    msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
                     else:
    -                    msg = f"{msg} {escape(obj)}"
    -                messages.success(request, mark_safe(msg))
    +                    msg = format_html("{} {}", msg, obj)
    +                messages.success(request, msg)
     
                     if "_addanother" in request.POST:
                         # If the object has clone_fields, pre-populate a new instance of the form
    @@ -1562,8 +1561,8 @@ def post(self, request, pk):
                 object_pk=post_data["object_pk"],
                 object_model_name=post_data["object_model_name"],
             )
    -        msg = f'Job enqueued. <a href="{result.get_absolute_url()}">Click here for the results.</a>'
    -        messages.info(request=request, message=mark_safe(msg))
    +        msg = format_html('Job enqueued. <a href="{}">Click here for the results.</a>', result.get_absolute_url())
    +        messages.info(request=request, message=msg)
             return redirect(post_data["redirect_path"])
     
     
    @@ -2037,10 +2036,10 @@ def post(self, request, *args, **kwargs):
                     msg = f"{verb} {self.queryset.model._meta.verbose_name}"
                     logger.info(f"{msg} {obj} (PK: {obj.pk})")
                     if hasattr(obj, "get_absolute_url"):
    -                    msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
    +                    msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
                     else:
    -                    msg = f"{msg} {escape(obj)}"
    -                messages.success(request, mark_safe(msg))
    +                    msg = format_html("{} {}", msg, obj)
    +                messages.success(request, msg)
     
                     if "_addanother" in request.POST:
                         # If the object has clone_fields, pre-populate a new instance of the form
    
  • nautobot/ipam/tables.py+1 1 modified
    @@ -33,7 +33,7 @@
         VRFPrefixAssignment,
     )
     
    -AVAILABLE_LABEL = mark_safe('<span class="label label-success">Available</span>')
    +AVAILABLE_LABEL = mark_safe('<span class="label label-success">Available</span>')  # noqa: S308
     
     UTILIZATION_GRAPH = """
     {% load helpers %}
    
  • nautobot/ipam/views.py+96 58 modified
    @@ -10,9 +10,8 @@
     from django.templatetags.static import static
     from django.shortcuts import get_object_or_404, redirect, render
     from django.urls import reverse
    -from django.utils.html import escape
    +from django.utils.html import format_html
     from django.utils.http import urlencode
    -from django.utils.safestring import mark_safe
     from django.views.generic import View
     from django_tables2 import RequestConfig
     
    @@ -575,20 +574,27 @@ def successful_post(self, request, obj, created, _logger):
             """Check for data that will be invalid in a future Nautobot release and warn the user if found."""
             # 3.0 TODO: remove these checks after enabling strict enforcement of the equivalent logic in Prefix.save()
             edit_url = reverse("ipam:prefix_edit", kwargs={"pk": obj.pk})
    -        warning_msg = (
    -            '<p>This <a href="'
    -            + static("docs/models/ipam/prefix.html")
    -            + '#prefix-hierarchy">will be considered invalid data</a> in a future release.</p>'
    +        warning_msg = format_html(
    +            '<p>This <a href="{}#prefix-hierarchy">will be considered invalid data</a> in a future release.</p>',
    +            static("docs/models/ipam/prefix.html"),
             )
             if obj.parent and obj.parent.type != constants.PREFIX_ALLOWED_PARENT_TYPES[obj.type]:
                 parent_edit_url = reverse("ipam:prefix_edit", kwargs={"pk": obj.parent.pk})
                 messages.warning(
                     request,
    -                mark_safe(
    -                    f'{obj} is a {obj.type.title()} prefix but its parent <a href="{obj.parent.get_absolute_url()}">'
    -                    f"{obj.parent}</a> is a {obj.parent.type.title()}. {warning_msg} "
    -                    f'Consider <a href="{edit_url}">changing the type of {obj}</a> and/or '
    -                    f'<a href="{parent_edit_url}">{obj.parent}</a> to resolve this issue.'
    +                format_html(
    +                    '{} is a {} prefix but its parent <a href="{}">{}</a> is a {}. {} Consider '
    +                    '<a href="{}">changing the type of {}</a> and/or <a href="{}">{}</a> to resolve this issue.',
    +                    obj,
    +                    obj.type.title(),
    +                    obj.parent.get_absolute_url(),
    +                    obj.parent,
    +                    obj.parent.type.title(),
    +                    warning_msg,
    +                    edit_url,
    +                    obj,
    +                    parent_edit_url,
    +                    obj.parent,
                     ),
                 )
     
    @@ -597,45 +603,59 @@ def successful_post(self, request, obj, created, _logger):
             )
     
             if invalid_children.exists():
    -            children_link = '<a href="' + reverse("ipam:prefix_list") + f'?parent={obj.pk}">its children</a>'
    +            children_link = format_html('<a href="{}?parent={}">its children</a>', reverse("ipam:prefix_list"), obj.pk)
                 if obj.type == choices.PrefixTypeChoices.TYPE_CONTAINER:
                     messages.warning(
                         request,
    -                    mark_safe(
    -                        f"{obj} is a Container prefix and should not contain child prefixes of type Pool. "
    -                        f"{warning_msg} Consider creating an intermediary Network prefix, or changing "
    -                        f"the type of {children_link} to Network, to resolve this issue."
    +                    format_html(
    +                        "{} is a Container prefix and should not contain child prefixes of type Pool. {} "
    +                        "Consider creating an intermediary Network prefix, or changing the type of {} to Network, "
    +                        "to resolve this issue.",
    +                        obj,
    +                        warning_msg,
    +                        children_link,
                         ),
                     )
                 elif obj.type == choices.PrefixTypeChoices.TYPE_NETWORK:
                     messages.warning(
                         request,
    -                    mark_safe(
    -                        f"{obj} is a Network prefix and should not contain child prefixes of types Container or "
    -                        f'Network. {warning_msg} Consider <a href="{edit_url}">changing the type of {obj}</a> '
    -                        f"to Container, or changing the type of {children_link} to Pool, to resolve this issue."
    +                    format_html(
    +                        "{} is a Network prefix and should not contain child prefixes of types Container or Network. "
    +                        '{} Consider <a href="{}">changing the type of {}</a> to Container, '
    +                        "or changing the type of {} to Pool, to resolve this issue.",
    +                        obj,
    +                        warning_msg,
    +                        edit_url,
    +                        obj,
    +                        children_link,
                         ),
                     )
                 else:  # TYPE_POOL
                     messages.warning(
                         request,
    -                    mark_safe(
    -                        f"{obj} is a Pool prefix and should not contain other prefixes. {warning_msg} "
    -                        f'Consider either <a href="{edit_url}">changing the type of {obj}</a> '
    -                        f"to Container or Network, or deleting {children_link}, to resolve this issue."
    +                    format_html(
    +                        "{} is a Pool prefix and should not contain other prefixes. {} "
    +                        'Consider either <a href="{}">changing the type of {}</a> to Container or Network, '
    +                        "or deleting {}, to resolve this issue.",
    +                        obj,
    +                        warning_msg,
    +                        edit_url,
    +                        obj,
    +                        children_link,
                         ),
                     )
     
             if obj.ip_addresses.exists() and obj.type == choices.PrefixTypeChoices.TYPE_CONTAINER:
    -            ip_warning_msg = (
    -                '<p>This <a href="'
    -                + static("docs/models/ipam/ipaddress.html")
    -                + '#ipaddress-parenting-concrete-relationship">will be considered invalid data</a> '
    -                "in a future release.</p>"
    +            ip_warning_msg = format_html(
    +                '<p>This <a href="{}#ipaddress-parenting-concrete-relationship">will be considered invalid data</a> '
    +                "in a future release.</p>",
    +                static("docs/models/ipam/ipaddress.html"),
                 )
                 shortest_child_mask_length = min([ip.mask_length for ip in obj.ip_addresses.all()])
                 if shortest_child_mask_length > obj.prefix_length:
    -                ip_link = '<a href="' + reverse("ipam:ipaddress_list") + f'?parent={obj.pk}">these IP addresses</a>'
    +                ip_link = format_html(
    +                    '<a href="{}?parent={}">these IP addresses</a>', reverse("ipam:ipaddress_list"), obj.pk
    +                )
                     create_url = reverse("ipam:prefix_add") + urlencode(
                         {
                             "namespace": obj.namespace.pk,
    @@ -645,20 +665,29 @@ def successful_post(self, request, obj, created, _logger):
                     )
                     messages.warning(
                         request,
    -                    mark_safe(
    -                        f"{obj} is a Container prefix and should not directly contain IP addresses. {ip_warning_msg} "
    -                        f'Consider either <a href="{edit_url}">changing the type of {obj}</a> to Network, or '
    -                        f'<a href="{create_url}">creating one or more child prefix(es) of type Network</a> to contain '
    -                        f"{ip_link}, to resolve this issue."
    +                    format_html(
    +                        "{} is a Container prefix and should not directly contain IP addresses. {} "
    +                        'Consider either <a href="{}">changing the type of {}</a> to Network, '
    +                        'or <a href="{}">creating one or more child prefix(es) of type Network</a> to contain {}, '
    +                        "to resolve this issue.",
    +                        obj,
    +                        ip_warning_msg,
    +                        edit_url,
    +                        obj,
    +                        create_url,
    +                        ip_link,
                         ),
                     )
                 else:
                     messages.warning(
                         request,
    -                    mark_safe(
    -                        f"{obj} is a Container prefix and should not directly contain IP addresses. {ip_warning_msg} "
    -                        f'Consider <a href="{edit_url}">changing the type of {obj}</a> to Network '
    -                        "to resolve this issue."
    +                    format_html(
    +                        "{} is a Container prefix and should not directly contain IP addresses. {} "
    +                        'Consider <a href="{}">changing the type of {}</a> to Network to resolve this issue.',
    +                        obj,
    +                        ip_warning_msg,
    +                        edit_url,
    +                        obj,
                         ),
                     )
     
    @@ -763,13 +792,12 @@ def successful_post(self, request, obj, created, _logger):
             """Check for data that will be invalid in a future Nautobot release and warn the user if found."""
             # 3.0 TODO: remove this check after enabling strict enforcement of the equivalent logic in IPAddress.save()
             if obj.parent.type == choices.PrefixTypeChoices.TYPE_CONTAINER:
    -            warning_msg = (
    -                '<p>This <a href="'
    -                + static("docs/models/ipam/ipaddress.html")
    -                + '#ipaddress-parenting-concrete-relationship">will be considered invalid data</a> '
    -                "in a future release.</p>"
    +            warning_msg = format_html(
    +                '<p>This <a href="{}#ipaddress-parenting-concrete-relationship">will be considered invalid data</a> '
    +                "in a future release.</p>",
    +                static("docs/models/ipam/ipaddress.html"),
                 )
    -            parent_link = f'<a href="{obj.parent.get_absolute_url()}">{obj.parent}</a>'
    +            parent_link = format_html('<a href="{}">{}</a>', obj.parent.get_absolute_url(), obj.parent)
                 if obj.parent.prefix_length < obj.mask_length:
                     create_url = (
                         reverse("ipam:prefix_add")
    @@ -784,20 +812,27 @@ def successful_post(self, request, obj, created, _logger):
                     )
                     messages.warning(
                         request,
    -                    mark_safe(
    -                        f"IP address {obj} currently has prefix {parent_link} as its parent, which is a Container. "
    -                        f'{warning_msg} Consider <a href="{create_url}">creating an intermediate /{obj.mask_length} '
    -                        "prefix of type Network</a> to resolve this issue."
    +                    format_html(
    +                        "IP address {} currently has prefix {} as its parent, which is a Container. {} "
    +                        'Consider <a href="{}">creating an intermediate /{} prefix of type Network</a> '
    +                        "to resolve this issue.",
    +                        obj,
    +                        parent_link,
    +                        warning_msg,
    +                        create_url,
    +                        obj.mask_length,
                         ),
                     )
                 else:
                     messages.warning(
                         request,
    -                    mark_safe(
    -                        f"IP address {obj} currently has prefix {parent_link} as its parent, which is a Container. "
    -                        f'{warning_msg} Consider <a href="'
    -                        + reverse("ipam:prefix_edit", kwargs={"pk": obj.parent.pk})
    -                        + '">changing the prefix</a> to type Network or Pool to resolve this issue.'
    +                    format_html(
    +                        "IP address {} currently has prefix {} as its parent, which is a Container. {} "
    +                        'Consider <a href="{}">changing the prefix</a> to type Network or Pool to resolve this issue.',
    +                        obj,
    +                        parent_link,
    +                        warning_msg,
    +                        reverse("ipam:prefix_edit", kwargs={"pk": obj.parent.pk}),
                         ),
                     )
     
    @@ -1023,9 +1058,12 @@ def post(self, request):
                             logger.info("Caught ProtectedError while attempting to delete objects")
                             handle_protectederror(collapsed_ips, request, e)
                             return redirect(self.get_return_url(request))
    -                    msg = (
    -                        f"Merged {deleted_count} {self.queryset.model._meta.verbose_name} "
    -                        f'into <a href="{merged_ip.get_absolute_url()}">{escape(merged_ip)}</a>'
    +                    msg = format_html(
    +                        'Merged {} {} into <a href="{}">{}</a>',
    +                        deleted_count,
    +                        self.queryset.model._meta.verbose_name,
    +                        merged_ip.get_absolute_url(),
    +                        merged_ip,
                         )
                         logger_msg = f"Merged {deleted_count} {self.queryset.model._meta.verbose_name} into {merged_ip}"
                         merged_ip.validated_save()
    @@ -1041,7 +1079,7 @@ def post(self, request):
                         for service in services:
                             Service.objects.get(pk=service).ip_addresses.add(merged_ip)
                         logger.info(logger_msg)
    -                    messages.success(request, mark_safe(msg))
    +                    messages.success(request, msg)
             return self.find_duplicate_ips(request, merged_attributes)
     
     
    
  • poetry.lock+27 1 modified
    @@ -3365,6 +3365,32 @@ files = [
     [package.dependencies]
     pyasn1 = ">=0.1.3"
     
    +[[package]]
    +name = "ruff"
    +version = "0.1.6"
    +description = "An extremely fast Python linter and code formatter, written in Rust."
    +optional = false
    +python-versions = ">=3.7"
    +files = [
    +    {file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:88b8cdf6abf98130991cbc9f6438f35f6e8d41a02622cc5ee130a02a0ed28703"},
    +    {file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c549ed437680b6105a1299d2cd30e4964211606eeb48a0ff7a93ef70b902248"},
    +    {file = "ruff-0.1.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cf5f701062e294f2167e66d11b092bba7af6a057668ed618a9253e1e90cfd76"},
    +    {file = "ruff-0.1.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:05991ee20d4ac4bb78385360c684e4b417edd971030ab12a4fbd075ff535050e"},
    +    {file = "ruff-0.1.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87455a0c1f739b3c069e2f4c43b66479a54dea0276dd5d4d67b091265f6fd1dc"},
    +    {file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:683aa5bdda5a48cb8266fcde8eea2a6af4e5700a392c56ea5fb5f0d4bfdc0240"},
    +    {file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:137852105586dcbf80c1717facb6781555c4e99f520c9c827bd414fac67ddfb6"},
    +    {file = "ruff-0.1.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd98138a98d48a1c36c394fd6b84cd943ac92a08278aa8ac8c0fdefcf7138f35"},
    +    {file = "ruff-0.1.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0cd909d25f227ac5c36d4e7e681577275fb74ba3b11d288aff7ec47e3ae745"},
    +    {file = "ruff-0.1.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8fd1c62a47aa88a02707b5dd20c5ff20d035d634aa74826b42a1da77861b5ff"},
    +    {file = "ruff-0.1.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd89b45d374935829134a082617954120d7a1470a9f0ec0e7f3ead983edc48cc"},
    +    {file = "ruff-0.1.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:491262006e92f825b145cd1e52948073c56560243b55fb3b4ecb142f6f0e9543"},
    +    {file = "ruff-0.1.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ea284789861b8b5ca9d5443591a92a397ac183d4351882ab52f6296b4fdd5462"},
    +    {file = "ruff-0.1.6-py3-none-win32.whl", hash = "sha256:1610e14750826dfc207ccbcdd7331b6bd285607d4181df9c1c6ae26646d6848a"},
    +    {file = "ruff-0.1.6-py3-none-win_amd64.whl", hash = "sha256:4558b3e178145491e9bc3b2ee3c4b42f19d19384eaa5c59d10acf6e8f8b57e33"},
    +    {file = "ruff-0.1.6-py3-none-win_arm64.whl", hash = "sha256:03910e81df0d8db0e30050725a5802441c2022ea3ae4fe0609b76081731accbc"},
    +    {file = "ruff-0.1.6.tar.gz", hash = "sha256:1b09f29b16c6ead5ea6b097ef2764b42372aebe363722f1605ecbcd2b9207184"},
    +]
    +
     [[package]]
     name = "rx"
     version = "1.6.3"
    @@ -4033,4 +4059,4 @@ sso = ["social-auth-core"]
     [metadata]
     lock-version = "2.0"
     python-versions = ">=3.8,<3.12"
    -content-hash = "5f9bca6f1bca37e007a5611ec280e9842d28d77bf72c68d4f90ff627fd569d00"
    +content-hash = "4709a608499593b8cdb5a9b38b34544891123dc1d86885de7a751c901ab07c4b"
    
  • pyproject.toml+22 0 modified
    @@ -199,6 +199,8 @@ isort = "~5.12.0"
     pylint = "~2.17.7"
     # Pylint extensions for Django
     pylint-django = "~2.5.3"
    +# Combination linter and code formatter
    +ruff = "~0.1.6"
     # YAML linting
     yamllint = "~1.30.0"
     
    @@ -341,6 +343,26 @@ notes = """,
     # @patch changes the signature of a function it's applied to; don't raise "no-value-for-parameter" here
     signature-mutators=["unittest.mock.patch"]
     
    +[tool.ruff]
    +line-length = 120
    +target-version = "py38"
    +
    +[tool.ruff.lint]
    +select = [
    +    "E",  # pycodestyle
    +    "F",  # pyflakes
    +    # "I",  # isort
    +    "S308",  # flake8-bandit: suspicious-mark-safe-usage
    +    "W",  # pycodestyle
    +]
    +ignore = [
    +    "E501",  # pycodestyle: line-too-long
    +]
    +
    +[tool.ruff.lint.isort]
    +lines-after-imports = 2
    +force-sort-within-sections = true
    +
     [tool.towncrier]
     package = "nautobot"
     directory = "changes"
    
  • scripts/git-hooks/pre-commit+3 0 modified
    @@ -45,6 +45,9 @@ else
         invoke markdownlint || EXIT=1
     fi
     
    +echo "Check static analysis via ruff..."
    +invoke ruff || EXIT=1
    +
     echo "Check static analysis via pylint..."
     invoke pylint || EXIT=1
     
    
  • tasks.py+8 0 modified
    @@ -611,6 +611,13 @@ def pylint(context, target=None, recursive=False):
             run_command(context, command)
     
     
    +@task
    +def ruff(context, output_format="text"):
    +    """Run ruff to perform static analysis and linting."""
    +    command = f"ruff --output-format {output_format} development/ examples/ nautobot/ tasks.py"
    +    run_command(context, command)
    +
    +
     @task
     def yamllint(context):
         """Run yamllint to validate formatting applies to YAML standards."""
    @@ -998,6 +1005,7 @@ def tests(context, lint_only=False, keepdb=False):
         hadolint(context)
         markdownlint(context)
         yamllint(context)
    +    ruff(context)
         pylint(context)
         check_migrations(context)
         check_schema(context)
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

10

News mentions

0

No linked articles in our index yet.