VYPR
Low severityNVD Advisory· Published Dec 22, 2023· Updated Aug 2, 2024

Nautobot missing object-level permissions enforcement when running Job Buttons

CVE-2023-51649

Description

Nautobot is a Network Source of Truth and Network Automation Platform built as a web application atop the Django Python framework with a PostgreSQL or MySQL database. When submitting a Job to run via a Job Button, only the model-level extras.run_job permission is checked (i.e., does the user have permission to run Jobs in general). Object-level permissions (i.e., does the user have permission to run this specific Job?) are not enforced by the URL/view used in this case. A user with permissions to run even a single Job can actually run all configured JobButton Jobs. Fix will be available in Nautobot 1.6.8 and 2.1.0

AI Insight

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

Nautobot fails to enforce object-level permissions when running Jobs via Job Buttons, allowing users to run any JobButton Job with just general run_job permission.

Vulnerability

Description

CVE-2023-51649 is an authorization bypass vulnerability in Nautobot, a Network Source of Truth and Network Automation Platform. The issue lies in the Job Button feature, where the URL/view handling the job submission only checks the model-level extras.run_job permission—i.e., whether the user has permission to run Jobs in general. It does not enforce object-level permissions that determine whether the user is allowed to run a specific Job. Consequently, any user who has been granted permission to run even a single Job can submit and execute all configured JobButton Jobs, regardless of whether they should be authorized to run each individual Job [1][2].

Exploitation

Prerequisites

To exploit this vulnerability, an attacker must have a valid Nautobot account with the extras.run_job permission. This permission grants the ability to run jobs, but object-level restrictions (e.g., per-Job permissions) are not enforced when using the Job Button interface. No special network position or additional authentication is required beyond the user's existing privileges [2]. The vulnerability affects the Job Button submission process, where hidden form fields such as redirect_path are replaced with _schedule_type and _return_url in the fix, indicating that the original logic did not adequately scope the job execution to the specific Job object [3][4].

Impact

An authenticated user with baseline job-running privileges can execute any Job that has been configured as a JobButton. This allows unauthorized job execution, potentially leading to arbitrary network automation actions, configuration changes, or other operations that the Nautobot instance supports. The impact depends on the permissions and capabilities of the jobs themselves, but the core issue is a privilege escalation where object-level access controls are bypassed [1][2].

Mitigation

The vulnerability is fixed in Nautobot versions 1.6.8 and 2.1.0 [1][2]. Administrators should upgrade to these versions or later to enforce object-level permission checks on Job Button submissions. The fix involves importing both Job and JobButton models and restructuring the templatetag to include proper permission validation per job [3][4]. No workarounds are mentioned, so upgrading is the recommended course of action.

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

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
nautobotPyPI
>= 1.5.14, < 1.6.81.6.8
nautobotPyPI
>= 2.0.0, < 2.1.02.1.0

Affected products

2
  • ghsa-coords
    Range: >= 1.5.14, < 1.6.8
  • nautobot/nautobotv5
    Range: >= 1.5.14, < 1.6.8

Patches

2
d33d0c15a369

[1.6] Fix object permissions enforcement on Job Buttons (#4995)

https://github.com/nautobot/nautobotGlenn MatthewsDec 20, 2023via ghsa
9 files changed · +202 133
  • changes/4988.housekeeping+1 0 added
    @@ -0,0 +1 @@
    +Fixed some bugs in `example_plugin.jobs.ExampleComplexJobButtonReceiver`.
    
  • changes/4988.removed+1 0 added
    @@ -0,0 +1 @@
    +Removed redundant `/extras/job-button/<uuid>/run/` URL endpoint; Job Buttons now use `/extras/jobs/<uuid>/run/` endpoint like any other job.
    
  • changes/4988.security+2 0 added
    @@ -0,0 +1,2 @@
    +Fixed missing object-level permissions enforcement when running a JobButton.
    +Removed the requirement for users to have both `extras.run_job` and `extras.run_jobbutton` permissions to run a Job via a Job Button. Only `extras.run_job` permission is now required.
    
  • examples/example_plugin/example_plugin/jobs.py+3 2 modified
    @@ -113,12 +113,13 @@ def receive_job_button(self, obj):
                     self.log_failure(obj=obj, message=f"User '{user}' does not have permission to add a Site.")
                 else:
                     self._run_site_job(obj)
    -        if isinstance(obj, Device):
    +        elif isinstance(obj, Device):
                 if not user.has_perm("dcim.add_device"):
                     self.log_failure(obj=obj, message=f"User '{user}' does not have permission to add a Device.")
                 else:
                     self._run_device_job(obj)
    -        self.log_failure(obj=obj, message=f"Unable to run Job Button for type {type(obj).__name__}.")
    +        else:
    +            self.log_failure(obj=obj, message=f"Unable to run Job Button for type {type(obj).__name__}.")
     
     
     jobs = (
    
  • nautobot/docs/models/extras/jobbutton.md+4 1 modified
    @@ -29,7 +29,10 @@ For any Job that is loaded into Nautobot, the Job must be enabled to run. See [E
     ## Required Permissions
     
     !!! note
    -    In order to run any job via a Job Button, a user must be assigned the `extras.run_job` **as well as** the `extras.run_jobbutton` permissions. This is achieved by assigning the user (or group) a permission on the `extras > job` and `extras > jobbutton` objects and specifying the `run` action in the **Additional actions** section. Any user lacking these permissions may still see the button on the respective page(s) - if not using [conditional rendering](#conditional-rendering) - but they will be disabled.
    +    In order to run any job via a Job Button, a user must be assigned the `extras.run_job` permission. This is achieved by assigning the user (or group) a permission on the `extras > job` objects and specifying the `run` action in the **Additional actions** section. Any user lacking this permissions may still see the button on the respective page(s) - if not using [conditional rendering](#conditional-rendering) - but it will be disabled.
    +
    ++/- 1.6.8
    +    In prior versions, users also had to have `extras.run_jobbutton` permission as well. This requirement has been removed.
     
     ## Context Data
     
    
  • nautobot/extras/templatetags/job_buttons.py+76 82 modified
    @@ -6,7 +6,7 @@
     from django.utils.html import format_html
     from django.utils.safestring import mark_safe
     
    -from nautobot.extras.models import JobButton
    +from nautobot.extras.models import Job, JobButton
     from nautobot.utilities.utils import render_jinja2
     
     
    @@ -27,7 +27,8 @@
     <input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">
     <input type="hidden" name="object_pk" value="{object_pk}">
     <input type="hidden" name="object_model_name" value="{object_model_name}">
    -<input type="hidden" name="redirect_path" value="{redirect_path}">
    +<input type="hidden" name="_schedule_type" value="immediately">
    +<input type="hidden" name="_return_url" value="{redirect_path}">
     """
     
     NO_CONFIRM_BUTTON = """
    @@ -69,115 +70,108 @@
     </div>
     """
     
    +SAFE_EMPTY_STR = mark_safe("")  # noqa: S308
     
    -@register.simple_tag(takes_context=True)
    -def job_buttons(context, obj):
    -    """
    -    Render all applicable job buttons for the given object.
    +
    +def _render_job_button_for_obj(job_button, obj, context, content_type):
         """
    -    content_type = ContentType.objects.get_for_model(obj)
    -    buttons = JobButton.objects.filter(content_types=content_type)
    -    if not buttons:
    -        return ""
    +    Helper method for job_buttons templatetag to reduce repetition of code.
     
    -    # Pass select context data when rendering the JobButton
    +    Returns:
    +       (str, str): (button_html, form_html)
    +    """
    +    # Pass select context data when rendering the JobButton text as Jinja2
         button_context = {
             "obj": obj,
             "debug": context.get("debug", False),  # django.template.context_processors.debug
             "request": context["request"],  # django.template.context_processors.request
             "user": context["user"],  # django.contrib.auth.context_processors.auth
             "perms": context["perms"],  # django.contrib.auth.context_processors.auth
         }
    -    buttons_html = forms_html = mark_safe("")  # noqa: S308
    -    group_names = OrderedDict()
    -
    +    try:
    +        text_rendered = render_jinja2(job_button.text, button_context)
    +    except Exception as exc:
    +        return (
    +            format_html(
    +                '<a class="btn btn-sm btn-{}" disabled="disabled" title="{}"><i class="mdi mdi-alert"></i> {}</a>\n',
    +                "default" if not job_button.group_name else "link",
    +                exc,
    +                job_button.name,
    +            ),
    +            SAFE_EMPTY_STR,
    +        )
    +
    +    if not text_rendered:
    +        return (SAFE_EMPTY_STR, SAFE_EMPTY_STR)
    +
    +    # Disable buttons if the user doesn't have permission to run the underlying Job.
    +    has_run_perm = Job.objects.check_perms(context["user"], instance=job_button.job, action="run")
         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}",
             redirect_path=context["request"].path,
         )
    +    template_args = {
    +        "button_id": job_button.pk,
    +        "button_text": text_rendered,
    +        "button_class": job_button.button_class if not job_button.group_name else "link",
    +        "button_url": reverse("extras:job_run", kwargs={"slug": job_button.job.slug}),
    +        "object": obj,
    +        "job": job_button.job,
    +        "hidden_inputs": hidden_inputs,
    +        "disabled": "" if has_run_perm else "disabled",
    +    }
    +
    +    if job_button.confirmation:
    +        return (
    +            format_html(CONFIRM_BUTTON, **template_args),
    +            format_html(CONFIRM_MODAL, **template_args),
    +        )
    +    else:
    +        return (
    +            format_html(NO_CONFIRM_BUTTON, **template_args),
    +            format_html(NO_CONFIRM_FORM, **template_args),
    +        )
    +
    +
    +@register.simple_tag(takes_context=True)
    +def job_buttons(context, obj):
    +    """
    +    Render all applicable job buttons for the given object.
    +    """
    +    content_type = ContentType.objects.get_for_model(obj)
    +    # We will enforce "run" permission later in deciding which buttons to show as disabled.
    +    buttons = JobButton.objects.filter(content_types=content_type)
    +    if not buttons:
    +        return SAFE_EMPTY_STR
    +
    +    buttons_html = forms_html = SAFE_EMPTY_STR
    +    group_names = OrderedDict()
     
         for jb in buttons:
    -        template_args = {
    -            "button_id": jb.pk,
    -            "button_text": jb.text,
    -            "button_class": jb.button_class,
    -            "button_url": reverse("extras:jobbutton_run", kwargs={"pk": jb.pk}),
    -            "object": obj,
    -            "job": jb.job,
    -            "hidden_inputs": hidden_inputs,
    -            "disabled": "" if context["user"].has_perms(("extras.run_jobbutton", "extras.run_job")) else "disabled",
    -        }
    -
    -        # Organize job buttons by group
    +        # Organize job buttons by group for later processing
             if jb.group_name:
    -            group_names.setdefault(jb.group_name, [])
    -            group_names[jb.group_name].append(jb)
    +            group_names.setdefault(jb.group_name, []).append(jb)
     
    -        # Add non-grouped buttons
    +        # Render and add non-grouped buttons
             else:
    -            try:
    -                text_rendered = render_jinja2(jb.text, button_context)
    -                if text_rendered:
    -                    template_args["button_text"] = text_rendered
    -                    if jb.confirmation:
    -                        buttons_html += format_html(CONFIRM_BUTTON, **template_args)
    -                        forms_html += format_html(CONFIRM_MODAL, **template_args)
    -                    else:
    -                        buttons_html += format_html(NO_CONFIRM_BUTTON, **template_args)
    -                        forms_html += format_html(NO_CONFIRM_FORM, **template_args)
    -            except Exception as e:
    -                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,
    -                )
    +            button_html, form_html = _render_job_button_for_obj(jb, obj, context, content_type)
    +            buttons_html += button_html
    +            forms_html += form_html
     
         # Add grouped buttons to template
         for group_name, buttons in group_names.items():
             group_button_class = buttons[0].button_class
     
    -        buttons_rendered = mark_safe("")  # noqa: S308
    +        buttons_rendered = SAFE_EMPTY_STR
     
             for jb in buttons:
    -            template_args = {
    -                "button_id": jb.pk,
    -                "button_text": jb.text,
    -                "button_class": "link",
    -                "button_url": reverse("extras:jobbutton_run", kwargs={"pk": jb.pk}),
    -                "object": obj,
    -                "job": jb.job,
    -                "hidden_inputs": hidden_inputs,
    -                "disabled": "" if context["user"].has_perms(("extras.run_jobbutton", "extras.run_job")) else "disabled",
    -            }
    -            try:
    -                text_rendered = render_jinja2(jb.text, button_context)
    -                if text_rendered:
    -                    template_args["button_text"] = text_rendered
    -                    if jb.confirmation:
    -                        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 += (
    -                            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 += format_html(
    -                    '<li><a disabled="disabled" title="{}"><span class="text-muted">'
    -                    '<i class="mdi mdi-alert"></i> {}</span></a></li>',
    -                    e,
    -                    jb.name,
    -                )
    +            # Render grouped buttons as list items
    +            button_html, form_html = _render_job_button_for_obj(jb, obj, context, content_type)
    +            buttons_rendered += format_html("<li>{}</li>", button_html)
    +            forms_html += form_html
     
             if buttons_rendered:
                 buttons_html += format_html(
    
  • nautobot/extras/tests/test_views.py+88 11 modified
    @@ -56,6 +56,7 @@
         Tag,
         Webhook,
     )
    +from nautobot.extras.templatetags.job_buttons import NO_CONFIRM_BUTTON
     from nautobot.extras.tests.constants import BIG_GRAPHQL_DEVICE_QUERY
     from nautobot.extras.tests.test_relationships import RequiredRelationshipTestMixin
     from nautobot.extras.utils import get_job_content_type, TaggableClassesQuery
    @@ -2012,14 +2013,24 @@ class JobButtonRenderingTestCase(TestCase):
     
         def setUp(self):
             super().setUp()
    -        self.job_button = JobButton(
    -            name="JobButton",
    +        self.job_button_1 = JobButton(
    +            name="JobButton 1",
                 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.job_button_1.validated_save()
    +        self.job_button_1.content_types.add(ContentType.objects.get_for_model(LocationType))
    +
    +        self.job_button_2 = JobButton(
    +            name="JobButton 2",
    +            text="Click me!",
    +            job=Job.objects.get(job_class_name="TestJobButtonReceiverComplex"),
    +            confirmation=False,
    +        )
    +        self.job_button_2.validated_save()
    +        self.job_button_2.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):
    @@ -2028,20 +2039,21 @@ def test_view_object_with_job_button(self):
             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)
    +        self.assertIn("Click me!", 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()
    +        self.job_button_1.text = '<script>alert("Hello world!")</script>'
    +        self.job_button_1.validated_save()
             response = self.client.get(self.location_type.get_absolute_url(), follow=True)
             self.assertEqual(response.status_code, 200)
             content = extract_page_body(response.content.decode(response.charset))
             self.assertNotIn("<script>alert", content, content)
             self.assertIn("&lt;script&gt;alert", content, content)
     
             # Make sure grouped rendering is safe too
    -        self.job_button.group = '<script>alert("Goodbye")</script>'
    -        self.job_button.validated_save()
    +        self.job_button_1.group_name = '<script>alert("Goodbye")</script>'
    +        self.job_button_1.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))
    @@ -2050,15 +2062,80 @@ def test_view_object_with_unsafe_text(self):
     
         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()
    +        self.job_button_1.text = "JobButton {{ obj"
    +        self.job_button_1.name = '<script>alert("Yo")</script>'
    +        self.job_button_1.validated_save()
             response = self.client.get(self.location_type.get_absolute_url(), follow=True)
             self.assertEqual(response.status_code, 200)
             content = extract_page_body(response.content.decode(response.charset))
             self.assertNotIn("<script>alert", content, content)
             self.assertIn("&lt;script&gt;alert", content, content)
     
    +    def test_render_constrained_run_permissions(self):
    +        obj_perm = ObjectPermission(
    +            name="Test permission",
    +            constraints={"pk": self.job_button_1.job.pk},
    +            actions=["run"],
    +        )
    +        obj_perm.save()
    +        obj_perm.users.add(self.user)
    +        obj_perm.object_types.add(ContentType.objects.get_for_model(Job))
    +
    +        with self.subTest("Ungrouped buttons"):
    +            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.assertInHTML(
    +                NO_CONFIRM_BUTTON.format(
    +                    button_id=self.job_button_1.pk,
    +                    button_text=f"JobButton {self.location_type.name}",
    +                    button_class=self.job_button_1.button_class,
    +                    disabled="",
    +                ),
    +                content,
    +            )
    +            self.assertInHTML(
    +                NO_CONFIRM_BUTTON.format(
    +                    button_id=self.job_button_2.pk,
    +                    button_text="Click me!",
    +                    button_class=self.job_button_2.button_class,
    +                    disabled="disabled",
    +                ),
    +                content,
    +            )
    +
    +        with self.subTest("Grouped buttons"):
    +            self.job_button_1.group_name = "Grouping"
    +            self.job_button_1.validated_save()
    +            self.job_button_2.group_name = "Grouping"
    +            self.job_button_2.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.assertInHTML(
    +                "<li>"
    +                + NO_CONFIRM_BUTTON.format(
    +                    button_id=self.job_button_1.pk,
    +                    button_text=f"JobButton {self.location_type.name}",
    +                    button_class="link",
    +                    disabled="",
    +                )
    +                + "</li>",
    +                content,
    +            )
    +            self.assertInHTML(
    +                "<li>"
    +                + NO_CONFIRM_BUTTON.format(
    +                    button_id=self.job_button_2.pk,
    +                    button_text="Click me!",
    +                    button_class="link",
    +                    disabled="disabled",
    +                )
    +                + "</li>",
    +                content,
    +            )
    +
     
     # TODO: Convert to StandardTestCases.Views
     class ObjectChangeTestCase(TestCase):
    
  • nautobot/extras/urls.py+0 2 modified
    @@ -485,8 +485,6 @@
             views.JobResultDeleteView.as_view(),
             name="jobresult_delete",
         ),
    -    # Job Button Run
    -    path("job-button/<uuid:pk>/run/", views.JobButtonRunView.as_view(), name="jobbutton_run"),
         # Notes
         path("notes/add/", views.NoteEditView.as_view(), name="note_add"),
         path("notes/<slug:slug>/", views.NoteView.as_view(), name="note"),
    
  • nautobot/extras/views.py+27 35 modified
    @@ -13,8 +13,9 @@
     from django.shortcuts import get_object_or_404, redirect, render
     from django.urls import reverse
     from django.utils import timezone
    +from django.utils.encoding import iri_to_uri
     from django.utils.html import format_html
    -from django.utils.http import is_safe_url
    +from django.utils.http import is_safe_url, url_has_allowed_host_and_scheme
     from django.views.generic import View
     from django.template.loader import get_template, TemplateDoesNotExist
     from django_tables2 import RequestConfig
    @@ -1131,14 +1132,23 @@ def post(self, request, class_path=None, slug=None):
             schedule_form = forms.JobScheduleForm(request.POST)
             task_queue = request.POST.get("_task_queue")
     
    +        return_url = request.POST.get("_return_url")
    +        if return_url is not None and url_has_allowed_host_and_scheme(url=return_url, allowed_hosts=request.get_host()):
    +            return_url = iri_to_uri(return_url)
    +        else:
    +            return_url = None
    +
             # Allow execution only if a worker process is running and the job is runnable.
             if not get_worker_count(queue=task_queue):
                 messages.error(request, "Unable to run or schedule job: Celery worker process not running.")
             elif not job_model.installed or job_model.job_class is None:
                 messages.error(request, "Unable to run or schedule job: Job is not presently installed.")
             elif not job_model.enabled:
                 messages.error(request, "Unable to run or schedule job: Job is not enabled to be run.")
    -        elif job_model.has_sensitive_variables and request.POST["_schedule_type"] != JobExecutionType.TYPE_IMMEDIATELY:
    +        elif (
    +            job_model.has_sensitive_variables
    +            and request.POST.get("_schedule_type") != JobExecutionType.TYPE_IMMEDIATELY
    +        ):
                 messages.error(request, "Unable to schedule job: Job may have sensitive input variables.")
             elif job_model.has_sensitive_variables and job_model.approval_required:
                 messages.error(
    @@ -1209,10 +1219,10 @@ def post(self, request, class_path=None, slug=None):
     
                     if job_model.approval_required:
                         messages.success(request, f"Job {schedule_name} successfully submitted for approval")
    -                    return redirect("extras:scheduledjob_approval_queue_list")
    +                    return redirect(return_url if return_url else "extras:scheduledjob_approval_queue_list")
                     else:
                         messages.success(request, f"Job {schedule_name} successfully scheduled")
    -                    return redirect("extras:scheduledjob_list")
    +                    return redirect(return_url if return_url else "extras:scheduledjob_list")
     
                 else:
                     # Enqueue job for immediate execution
    @@ -1230,8 +1240,21 @@ def post(self, request, class_path=None, slug=None):
                         task_queue=job_form.cleaned_data.get("_task_queue", None),
                     )
     
    +                if return_url:
    +                    messages.info(
    +                        request,
    +                        format_html(
    +                            'Job enqueued. <a href="{}">Click here for the results.</a>',
    +                            job_result.get_absolute_url(),
    +                        ),
    +                    )
    +                    return redirect(return_url)
    +
                     return redirect("extras:jobresult", pk=job_result.pk)
     
    +        if return_url:
    +            return redirect(return_url)
    +
             template_name = "extras/job.html"
             if job_model.job_class is not None and hasattr(job_model.job_class, "template_name"):
                 try:
    @@ -1589,37 +1612,6 @@ class JobButtonUIViewSet(NautobotUIViewSet):
         table_class = tables.JobButtonTable
     
     
    -class JobButtonRunView(ObjectPermissionRequiredMixin, View):
    -    """
    -    View to run the Job linked to the Job Button.
    -    """
    -
    -    queryset = JobButton.objects.all()
    -
    -    def get_required_permission(self):
    -        return "extras.run_job"
    -
    -    def post(self, request, pk):
    -        post_data = request.POST
    -        job_button = JobButton.objects.get(pk=pk)
    -        job_model = job_button.job
    -        result = JobResult.enqueue_job(
    -            func=run_job,
    -            name=job_model.class_path,
    -            obj_type=get_job_content_type(),
    -            user=request.user,
    -            data={
    -                "object_pk": post_data["object_pk"],
    -                "object_model_name": post_data["object_model_name"],
    -            },
    -            request=copy_safe_request(request),
    -            commit=True,
    -        )
    -        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"])
    -
    -
     #
     # Change logging
     #
    
3d964f996f49

Fix object permissions enforcement on Job Buttons (#4993)

https://github.com/nautobot/nautobotGlenn MatthewsDec 20, 2023via ghsa
9 files changed · +201 127
  • changes/4988.housekeeping+1 0 added
    @@ -0,0 +1 @@
    +Fixed some bugs in `example_plugin.jobs.ExampleComplexJobButtonReceiver`.
    
  • changes/4988.removed+1 0 added
    @@ -0,0 +1 @@
    +Removed redundant `/extras/job-button/<uuid>/run/` URL endpoint; Job Buttons now use `/extras/jobs/<uuid>/run/` endpoint like any other job.
    
  • changes/4988.security+2 0 added
    @@ -0,0 +1,2 @@
    +Fixed missing object-level permissions enforcement when running a JobButton.
    +Removed the requirement for users to have both `extras.run_job` and `extras.run_jobbutton` permissions to run a Job via a Job Button. Only `extras.run_job` permission is now required.
    
  • examples/example_plugin/example_plugin/jobs.py+4 3 modified
    @@ -152,18 +152,19 @@ def _run_device_job(self, obj):
             # Run Device Job function
     
         def receive_job_button(self, obj):
    -        user = self.request.user
    +        user = self.user
             if isinstance(obj, Location):
                 if not user.has_perm("dcim.add_location"):
                     self.logger.error("User '%s' does not have permission to add a Location.", user, extra={"object": obj})
                 else:
                     self._run_location_job(obj)
    -        if isinstance(obj, Device):
    +        elif isinstance(obj, Device):
                 if not user.has_perm("dcim.add_device"):
                     self.logger.error("User '%s' does not have permission to add a Device.", user, extra={"object": obj})
                 else:
                     self._run_device_job(obj)
    -        self.logger.error("Unable to run Job Button for type %s.", type(obj).__name__, extra={"object": obj})
    +        else:
    +            self.logger.error("Unable to run Job Button for type %s.", type(obj).__name__, extra={"object": obj})
     
     
     jobs = (
    
  • nautobot/docs/user-guide/platform-functionality/jobs/jobbutton.md+4 1 modified
    @@ -29,7 +29,10 @@ For any Job that is loaded into Nautobot, the Job must be enabled to run. See [E
     ## Required Permissions
     
     !!! note
    -    In order to run any job via a Job Button, a user must be assigned the `extras.run_job` **as well as** the `extras.run_jobbutton` permissions. This is achieved by assigning the user (or group) a permission on the `extras > job` and `extras > jobbutton` objects and specifying the `run` action in the **Additional actions** section. Any user lacking these permissions may still see the button on the respective page(s) - if not using [conditional rendering](#conditional-rendering) - but they will be disabled.
    +    In order to run any job via a Job Button, a user must be assigned the `extras.run_job` permission. This is achieved by assigning the user (or group) a permission on the `extras > job` objects and specifying the `run` action in the **Additional actions** section. Any user lacking this permissions may still see the button on the respective page(s) - if not using [conditional rendering](#conditional-rendering) - but it will be disabled.
    +
    ++/- 2.1.0
    +    In prior versions, users also had to have `extras.run_jobbutton` permission as well. This requirement has been removed.
     
     ## Context Data
     
    
  • nautobot/extras/templatetags/job_buttons.py+76 82 modified
    @@ -6,7 +6,7 @@
     from django.utils.html import format_html
     from django.utils.safestring import mark_safe
     
    -from nautobot.extras.models import JobButton
    +from nautobot.extras.models import Job, JobButton
     from nautobot.core.utils.data import render_jinja2
     
     
    @@ -27,7 +27,8 @@
     <input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">
     <input type="hidden" name="object_pk" value="{object_pk}">
     <input type="hidden" name="object_model_name" value="{object_model_name}">
    -<input type="hidden" name="redirect_path" value="{redirect_path}">
    +<input type="hidden" name="_schedule_type" value="immediately">
    +<input type="hidden" name="_return_url" value="{redirect_path}">
     """
     
     NO_CONFIRM_BUTTON = """
    @@ -69,115 +70,108 @@
     </div>
     """
     
    +SAFE_EMPTY_STR = mark_safe("")  # noqa: S308
     
    -@register.simple_tag(takes_context=True)
    -def job_buttons(context, obj):
    -    """
    -    Render all applicable job buttons for the given object.
    +
    +def _render_job_button_for_obj(job_button, obj, context, content_type):
         """
    -    content_type = ContentType.objects.get_for_model(obj)
    -    buttons = JobButton.objects.filter(content_types=content_type)
    -    if not buttons:
    -        return ""
    +    Helper method for job_buttons templatetag to reduce repetition of code.
     
    -    # Pass select context data when rendering the JobButton
    +    Returns:
    +       (str, str): (button_html, form_html)
    +    """
    +    # Pass select context data when rendering the JobButton text as Jinja2
         button_context = {
             "obj": obj,
             "debug": context.get("debug", False),  # django.template.context_processors.debug
             "request": context["request"],  # django.template.context_processors.request
             "user": context["user"],  # django.contrib.auth.context_processors.auth
             "perms": context["perms"],  # django.contrib.auth.context_processors.auth
         }
    -    buttons_html = forms_html = mark_safe("")  # noqa: S308
    -    group_names = OrderedDict()
    -
    +    try:
    +        text_rendered = render_jinja2(job_button.text, button_context)
    +    except Exception as exc:
    +        return (
    +            format_html(
    +                '<a class="btn btn-sm btn-{}" disabled="disabled" title="{}"><i class="mdi mdi-alert"></i> {}</a>\n',
    +                "default" if not job_button.group_name else "link",
    +                exc,
    +                job_button.name,
    +            ),
    +            SAFE_EMPTY_STR,
    +        )
    +
    +    if not text_rendered:
    +        return (SAFE_EMPTY_STR, SAFE_EMPTY_STR)
    +
    +    # Disable buttons if the user doesn't have permission to run the underlying Job.
    +    has_run_perm = Job.objects.check_perms(context["user"], instance=job_button.job, action="run")
         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}",
             redirect_path=context["request"].path,
         )
    +    template_args = {
    +        "button_id": job_button.pk,
    +        "button_text": text_rendered,
    +        "button_class": job_button.button_class if not job_button.group_name else "link",
    +        "button_url": reverse("extras:job_run", kwargs={"pk": job_button.job.pk}),
    +        "object": obj,
    +        "job": job_button.job,
    +        "hidden_inputs": hidden_inputs,
    +        "disabled": "" if has_run_perm else "disabled",
    +    }
    +
    +    if job_button.confirmation:
    +        return (
    +            format_html(CONFIRM_BUTTON, **template_args),
    +            format_html(CONFIRM_MODAL, **template_args),
    +        )
    +    else:
    +        return (
    +            format_html(NO_CONFIRM_BUTTON, **template_args),
    +            format_html(NO_CONFIRM_FORM, **template_args),
    +        )
    +
    +
    +@register.simple_tag(takes_context=True)
    +def job_buttons(context, obj):
    +    """
    +    Render all applicable job buttons for the given object.
    +    """
    +    content_type = ContentType.objects.get_for_model(obj)
    +    # We will enforce "run" permission later in deciding which buttons to show as disabled.
    +    buttons = JobButton.objects.filter(content_types=content_type)
    +    if not buttons:
    +        return SAFE_EMPTY_STR
    +
    +    buttons_html = forms_html = SAFE_EMPTY_STR
    +    group_names = OrderedDict()
     
         for jb in buttons:
    -        template_args = {
    -            "button_id": jb.pk,
    -            "button_text": jb.text,
    -            "button_class": jb.button_class,
    -            "button_url": reverse("extras:jobbutton_run", kwargs={"pk": jb.pk}),
    -            "object": obj,
    -            "job": jb.job,
    -            "hidden_inputs": hidden_inputs,
    -            "disabled": "" if context["user"].has_perms(("extras.run_jobbutton", "extras.run_job")) else "disabled",
    -        }
    -
    -        # Organize job buttons by group
    +        # Organize job buttons by group for later processing
             if jb.group_name:
    -            group_names.setdefault(jb.group_name, [])
    -            group_names[jb.group_name].append(jb)
    +            group_names.setdefault(jb.group_name, []).append(jb)
     
    -        # Add non-grouped buttons
    +        # Render and add non-grouped buttons
             else:
    -            try:
    -                text_rendered = render_jinja2(jb.text, button_context)
    -                if text_rendered:
    -                    template_args["button_text"] = text_rendered
    -                    if jb.confirmation:
    -                        buttons_html += format_html(CONFIRM_BUTTON, **template_args)
    -                        forms_html += format_html(CONFIRM_MODAL, **template_args)
    -                    else:
    -                        buttons_html += format_html(NO_CONFIRM_BUTTON, **template_args)
    -                        forms_html += format_html(NO_CONFIRM_FORM, **template_args)
    -            except Exception as e:
    -                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,
    -                )
    +            button_html, form_html = _render_job_button_for_obj(jb, obj, context, content_type)
    +            buttons_html += button_html
    +            forms_html += form_html
     
         # Add grouped buttons to template
         for group_name, buttons in group_names.items():
             group_button_class = buttons[0].button_class
     
    -        buttons_rendered = mark_safe("")  # noqa: S308
    +        buttons_rendered = SAFE_EMPTY_STR
     
             for jb in buttons:
    -            template_args = {
    -                "button_id": jb.pk,
    -                "button_text": jb.text,
    -                "button_class": "link",
    -                "button_url": reverse("extras:jobbutton_run", kwargs={"pk": jb.pk}),
    -                "object": obj,
    -                "job": jb.job,
    -                "hidden_inputs": hidden_inputs,
    -                "disabled": "" if context["user"].has_perms(("extras.run_jobbutton", "extras.run_job")) else "disabled",
    -            }
    -            try:
    -                text_rendered = render_jinja2(jb.text, button_context)
    -                if text_rendered:
    -                    template_args["button_text"] = text_rendered
    -                    if jb.confirmation:
    -                        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 += (
    -                            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 += format_html(
    -                    '<li><a disabled="disabled" title="{}"><span class="text-muted">'
    -                    '<i class="mdi mdi-alert"></i> {}</span></a></li>',
    -                    e,
    -                    jb.name,
    -                )
    +            # Render grouped buttons as list items
    +            button_html, form_html = _render_job_button_for_obj(jb, obj, context, content_type)
    +            buttons_rendered += format_html("<li>{}</li>", button_html)
    +            forms_html += form_html
     
             if buttons_rendered:
                 buttons_html += format_html(
    
  • nautobot/extras/tests/test_views.py+88 11 modified
    @@ -51,6 +51,7 @@
         Tag,
         Webhook,
     )
    +from nautobot.extras.templatetags.job_buttons import NO_CONFIRM_BUTTON
     from nautobot.extras.tests.constants import BIG_GRAPHQL_DEVICE_QUERY
     from nautobot.extras.tests.test_relationships import RequiredRelationshipTestMixin
     from nautobot.extras.utils import RoleModelsQuery, TaggableClassesQuery
    @@ -1995,14 +1996,24 @@ class JobButtonRenderingTestCase(TestCase):
     
         def setUp(self):
             super().setUp()
    -        self.job_button = JobButton(
    -            name="JobButton",
    +        self.job_button_1 = JobButton(
    +            name="JobButton 1",
                 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.job_button_1.validated_save()
    +        self.job_button_1.content_types.add(ContentType.objects.get_for_model(LocationType))
    +
    +        self.job_button_2 = JobButton(
    +            name="JobButton 2",
    +            text="Click me!",
    +            job=Job.objects.get(job_class_name="TestJobButtonReceiverComplex"),
    +            confirmation=False,
    +        )
    +        self.job_button_2.validated_save()
    +        self.job_button_2.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):
    @@ -2011,20 +2022,21 @@ def test_view_object_with_job_button(self):
             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)
    +        self.assertIn("Click me!", 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()
    +        self.job_button_1.text = '<script>alert("Hello world!")</script>'
    +        self.job_button_1.validated_save()
             response = self.client.get(self.location_type.get_absolute_url(), follow=True)
             self.assertEqual(response.status_code, 200)
             content = extract_page_body(response.content.decode(response.charset))
             self.assertNotIn("<script>alert", content, content)
             self.assertIn("&lt;script&gt;alert", content, content)
     
             # Make sure grouped rendering is safe too
    -        self.job_button.group = '<script>alert("Goodbye")</script>'
    -        self.job_button.validated_save()
    +        self.job_button_1.group_name = '<script>alert("Goodbye")</script>'
    +        self.job_button_1.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))
    @@ -2033,15 +2045,80 @@ def test_view_object_with_unsafe_text(self):
     
         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()
    +        self.job_button_1.text = "JobButton {{ obj"
    +        self.job_button_1.name = '<script>alert("Yo")</script>'
    +        self.job_button_1.validated_save()
             response = self.client.get(self.location_type.get_absolute_url(), follow=True)
             self.assertEqual(response.status_code, 200)
             content = extract_page_body(response.content.decode(response.charset))
             self.assertNotIn("<script>alert", content, content)
             self.assertIn("&lt;script&gt;alert", content, content)
     
    +    def test_render_constrained_run_permissions(self):
    +        obj_perm = ObjectPermission(
    +            name="Test permission",
    +            constraints={"pk": self.job_button_1.job.pk},
    +            actions=["run"],
    +        )
    +        obj_perm.save()
    +        obj_perm.users.add(self.user)
    +        obj_perm.object_types.add(ContentType.objects.get_for_model(Job))
    +
    +        with self.subTest("Ungrouped buttons"):
    +            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.assertInHTML(
    +                NO_CONFIRM_BUTTON.format(
    +                    button_id=self.job_button_1.pk,
    +                    button_text=f"JobButton {self.location_type.name}",
    +                    button_class=self.job_button_1.button_class,
    +                    disabled="",
    +                ),
    +                content,
    +            )
    +            self.assertInHTML(
    +                NO_CONFIRM_BUTTON.format(
    +                    button_id=self.job_button_2.pk,
    +                    button_text="Click me!",
    +                    button_class=self.job_button_2.button_class,
    +                    disabled="disabled",
    +                ),
    +                content,
    +            )
    +
    +        with self.subTest("Grouped buttons"):
    +            self.job_button_1.group_name = "Grouping"
    +            self.job_button_1.validated_save()
    +            self.job_button_2.group_name = "Grouping"
    +            self.job_button_2.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.assertInHTML(
    +                "<li>"
    +                + NO_CONFIRM_BUTTON.format(
    +                    button_id=self.job_button_1.pk,
    +                    button_text=f"JobButton {self.location_type.name}",
    +                    button_class="link",
    +                    disabled="",
    +                )
    +                + "</li>",
    +                content,
    +            )
    +            self.assertInHTML(
    +                "<li>"
    +                + NO_CONFIRM_BUTTON.format(
    +                    button_id=self.job_button_2.pk,
    +                    button_text="Click me!",
    +                    button_class="link",
    +                    disabled="disabled",
    +                )
    +                + "</li>",
    +                content,
    +            )
    +
     
     # TODO: Convert to StandardTestCases.Views
     class ObjectChangeTestCase(TestCase):
    
  • nautobot/extras/urls.py+0 2 modified
    @@ -479,8 +479,6 @@
             views.JobResultDeleteView.as_view(),
             name="jobresult_delete",
         ),
    -    # Job Button Run
    -    path("job-button/<uuid:pk>/run/", views.JobButtonRunView.as_view(), name="jobbutton_run"),
         # Notes
         path("notes/", views.NoteListView.as_view(), name="note_list"),
         path("notes/add/", views.NoteEditView.as_view(), name="note_add"),
    
  • nautobot/extras/views.py+25 28 modified
    @@ -1138,14 +1138,23 @@ def post(self, request, class_path=None, pk=None):
             schedule_form = forms.JobScheduleForm(request.POST)
             task_queue = request.POST.get("_task_queue")
     
    +        return_url = request.POST.get("_return_url")
    +        if return_url is not None and url_has_allowed_host_and_scheme(url=return_url, allowed_hosts=request.get_host()):
    +            return_url = iri_to_uri(return_url)
    +        else:
    +            return_url = None
    +
             # Allow execution only if a worker process is running and the job is runnable.
             if not get_worker_count(queue=task_queue):
                 messages.error(request, "Unable to run or schedule job: Celery worker process not running.")
             elif not job_model.installed or job_model.job_class is None:
                 messages.error(request, "Unable to run or schedule job: Job is not presently installed.")
             elif not job_model.enabled:
                 messages.error(request, "Unable to run or schedule job: Job is not enabled to be run.")
    -        elif job_model.has_sensitive_variables and request.POST["_schedule_type"] != JobExecutionType.TYPE_IMMEDIATELY:
    +        elif (
    +            job_model.has_sensitive_variables
    +            and request.POST.get("_schedule_type") != JobExecutionType.TYPE_IMMEDIATELY
    +        ):
                 messages.error(request, "Unable to schedule job: Job may have sensitive input variables.")
             elif job_model.has_sensitive_variables and job_model.approval_required:
                 messages.error(
    @@ -1207,10 +1216,10 @@ def post(self, request, class_path=None, pk=None):
     
                     if job_model.approval_required:
                         messages.success(request, f"Job {schedule_name} successfully submitted for approval")
    -                    return redirect("extras:scheduledjob_approval_queue_list")
    +                    return redirect(return_url if return_url else "extras:scheduledjob_approval_queue_list")
                     else:
                         messages.success(request, f"Job {schedule_name} successfully scheduled")
    -                    return redirect("extras:scheduledjob_list")
    +                    return redirect(return_url if return_url else "extras:scheduledjob_list")
     
                 else:
                     # Enqueue job for immediate execution
    @@ -1223,8 +1232,21 @@ def post(self, request, class_path=None, pk=None):
                         **job_model.job_class.serialize_data(job_kwargs),
                     )
     
    +                if return_url:
    +                    messages.info(
    +                        request,
    +                        format_html(
    +                            'Job enqueued. <a href="{}">Click here for the results.</a>',
    +                            job_result.get_absolute_url(),
    +                        ),
    +                    )
    +                    return redirect(return_url)
    +
                     return redirect("extras:jobresult", pk=job_result.pk)
     
    +        if return_url:
    +            return redirect(return_url)
    +
             template_name = "extras/job.html"
             if job_model.job_class is not None and hasattr(job_model.job_class, "template_name"):
                 try:
    @@ -1542,31 +1564,6 @@ class JobButtonUIViewSet(NautobotUIViewSet):
         table_class = tables.JobButtonTable
     
     
    -class JobButtonRunView(ObjectPermissionRequiredMixin, View):
    -    """
    -    View to run the Job linked to the Job Button.
    -    """
    -
    -    queryset = JobButton.objects.all()
    -
    -    def get_required_permission(self):
    -        return "extras.run_job"
    -
    -    def post(self, request, pk):
    -        post_data = request.POST
    -        job_button = JobButton.objects.get(pk=pk)
    -        job_model = job_button.job
    -        result = JobResult.enqueue_job(
    -            job_model=job_model,
    -            user=request.user,
    -            object_pk=post_data["object_pk"],
    -            object_model_name=post_data["object_model_name"],
    -        )
    -        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"])
    -
    -
     #
     # Change logging
     #
    

Vulnerability mechanics

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

References

9

News mentions

0

No linked articles in our index yet.