From 0b192cf588c6efdc764c061548f0807f17668fba Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 12 Jun 2026 06:16:41 -0700 Subject: [PATCH] Closes #22411:Enforce token write ability when executing custom scripts via the REST API --- netbox/extras/api/views.py | 7 ++++++ netbox/extras/tests/test_api.py | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index faf8a9bec..79ba0eed5 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -21,6 +21,7 @@ from netbox.api.metadata import ContentTypeMetadata from netbox.api.renderers import TextRenderer from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet from netbox.api.viewsets.mixins import ObjectValidationMixin +from users.models import Token from utilities.exceptions import RQWorkerNotRunningException from utilities.request import copy_safe_request from utilities.rqworker import any_workers_for_queue @@ -332,6 +333,12 @@ class ScriptViewSet(ModelViewSet): Run a Script identified by its numeric PK or module & name and return the pending Job as the result """ + # Running a script is a state-changing operation. If token authentication is in use, enforce the token's + # write ability before performing any object lookup. Session-authenticated requests are unaffected + # (request.auth is not a Token). + if isinstance(request.auth, Token) and not request.auth.write_enabled: + raise PermissionDenied("This token does not permit write operations.") + script = self._get_script(pk) if not request.user.has_perm('extras.run_script', obj=script): diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index df94cbe0d..3a9703671 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -1239,6 +1239,45 @@ class ScriptTestCase(APITestCase): # Restore the original setting for other tests self.TestScriptClass.Meta.scheduling_enabled = original + def test_run_token_write_enabled(self): + """ + Running a script is an unsafe (state-changing) action and must be rejected when the calling token has + write_enabled=False, even if the user holds the run_script permission. + """ + self.add_permissions('extras.run_script') + payload = { + 'data': {'var1': 'hello', 'var2': 1, 'var3': False}, + 'commit': True, + } + + # A token with write_enabled=False should be rejected + token = Token.objects.create(version=2, user=self.user, write_enabled=False) + token_header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}' + response = self.client.post(self.url, payload, format='json', HTTP_AUTHORIZATION=token_header) + self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN) + + # Enabling write ability on the token should allow the script to run + token.write_enabled = True + token.save() + response = self.client.post(self.url, payload, format='json', HTTP_AUTHORIZATION=token_header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + def test_run_session_auth(self): + """ + The token write-ability check applies only to token authentication. Session-authenticated requests + (where request.auth is not a Token) must still be allowed to run scripts. + """ + self.add_permissions('extras.run_script') + payload = { + 'data': {'var1': 'hello', 'var2': 1, 'var3': False}, + 'commit': True, + } + + # Authenticate via session rather than a token; request.auth is None + self.client.force_authenticate(user=self.user) + response = self.client.post(self.url, payload, format='json') + self.assertHttpStatus(response, status.HTTP_200_OK) + class CreatedUpdatedFilterTestCase(APITestCase):