nautobot has XSS potential in custom links, job buttons, and computed fields
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.
| Package | Affected versions | Patched versions |
|---|---|---|
nautobotPyPI | < 1.6.6 | 1.6.6 |
nautobotPyPI | >= 2.0.0, < 2.0.5 | 2.0.5 |
Affected products
2- nautobot/nautobotv5Range: < 1.6.6
Patches
2362850f5a946[1.6] Fix unsafe `mark_safe` use in various features (#4833)
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("<script>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("<script>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("<script>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("<script>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("<script>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("<script>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("<script>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("<script>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}"> </span>') + return format_html('<span class="label color-block" style="background-color: #{}"> </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">—</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">—</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)
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}"> </span>') + return format_html('<span class="label color-block" style="background-color: #{}"> </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">—</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">—</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("<script>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("<script>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("<script>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("<script>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("<script>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("<script>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("<script>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("<script>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- github.com/advisories/GHSA-cf9f-wmhp-v4prghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-48705ghsaADVISORY
- docs.djangoproject.com/en/3.2/ref/utils/ghsax_refsource_MISCWEB
- docs.djangoproject.com/en/3.2/ref/utils/ghsax_refsource_MISCWEB
- github.com/nautobot/nautobot/commit/362850f5a94689a4c75e3188bf6de826c3b012b2ghsax_refsource_MISCWEB
- github.com/nautobot/nautobot/commit/54abe23331b6c3d0d82bf1b028c679b1d200920dghsax_refsource_MISCWEB
- github.com/nautobot/nautobot/pull/4832ghsax_refsource_MISCWEB
- github.com/nautobot/nautobot/pull/4833ghsax_refsource_MISCWEB
- github.com/nautobot/nautobot/security/advisories/GHSA-cf9f-wmhp-v4prghsax_refsource_CONFIRMWEB
- github.com/pypa/advisory-database/tree/main/vulns/nautobot/PYSEC-2023-285.yamlghsaWEB
News mentions
0No linked articles in our index yet.