From 6c18e6402005c0f105f88dfba4aaa11cf990a7e0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 15 Jun 2026 22:06:49 +1000 Subject: [PATCH] Permissions fix (#12168) * Tighten API permissions - Require authenticated user for NotFoundView - Hide 'active_plugins' behind is_authenticated * Patch permissions hole in GlobalSettingsPermissions * Additional API unit tests * Require auth for observability endpoint * Add explicit permission for PluginAdminDetail * Bump API version * Update unit tests * Revert changes --- src/backend/InvenTree/InvenTree/api.py | 7 ++- .../InvenTree/InvenTree/api_version.py | 5 ++- .../InvenTree/InvenTree/permissions.py | 1 - src/backend/InvenTree/InvenTree/test_api.py | 1 + src/backend/InvenTree/common/api.py | 2 +- src/backend/InvenTree/plugin/api.py | 26 ++++++++--- src/backend/InvenTree/plugin/test_api.py | 45 +++++++++++++++++++ 7 files changed, 75 insertions(+), 12 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 87edf69474..79b080bb1d 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -15,7 +15,7 @@ from django.views.generic.base import RedirectView import structlog from django_q.models import OrmQ from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema -from rest_framework import serializers, viewsets +from rest_framework import permissions, serializers, viewsets from rest_framework.generics import GenericAPIView from rest_framework.request import clone_request from rest_framework.response import Response @@ -356,7 +356,10 @@ class InfoView(APIView): class NotFoundView(APIView): """Simple JSON view when accessing an invalid API view.""" - permission_classes = [InvenTree.permissions.AllowAnyOrReadScope] + permission_classes = [ + permissions.IsAuthenticated, + InvenTree.permissions.AllowAnyOrReadScope, + ] def not_found(self, request): """Return a 404 error.""" diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 7498f02e5d..6fb2f44d88 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 505 +INVENTREE_API_VERSION = 506 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v506 -> 2026-06-15 : https://github.com/inventree/InvenTree/pull/12168 + - Reduce permissions scope for a number of API endpoints, to improve security and ensure that users only have access to the data they need + v505 -> 2026-06-15 : https://github.com/inventree/InvenTree/pull/12165 - Allow parameters to be specified against the PartCategory model diff --git a/src/backend/InvenTree/InvenTree/permissions.py b/src/backend/InvenTree/InvenTree/permissions.py index 47af7dabcc..060a698c83 100644 --- a/src/backend/InvenTree/InvenTree/permissions.py +++ b/src/backend/InvenTree/InvenTree/permissions.py @@ -415,7 +415,6 @@ class GlobalSettingsPermissions(OASTokenMixin, permissions.BasePermission): """Check that the requesting user is 'admin'.""" try: user = request.user - if request.method in permissions.SAFE_METHODS: return True # Any other methods require staff access permissions diff --git a/src/backend/InvenTree/InvenTree/test_api.py b/src/backend/InvenTree/InvenTree/test_api.py index 5475edd03a..028e305926 100644 --- a/src/backend/InvenTree/InvenTree/test_api.py +++ b/src/backend/InvenTree/InvenTree/test_api.py @@ -612,6 +612,7 @@ class GeneralApiTests(InvenTreeAPITestCase): response = self.get( url, headers={'Authorization': f'Token {token}'}, max_query_count=20 ) + self.assertIsNotNone(data.get('active_plugins')) self.assertGreater(len(response.json()['database']), 4) data = response.json() diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 964d9e7a77..36c8f46729 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -1405,7 +1405,7 @@ class ObservabilityEndSerializer(serializers.Serializer): class ObservabilityEnd(CreateAPI): """Endpoint for observability tools.""" - permission_classes = [AllowAnyOrReadScope] + permission_classes = [IsAuthenticated, AllowAnyOrReadScope] serializer_class = ObservabilityEndSerializer def create(self, request, *args, **kwargs): diff --git a/src/backend/InvenTree/plugin/api.py b/src/backend/InvenTree/plugin/api.py index 400f0004df..c8967f5da1 100644 --- a/src/backend/InvenTree/plugin/api.py +++ b/src/backend/InvenTree/plugin/api.py @@ -10,7 +10,7 @@ import django_filters.rest_framework.filters as rest_filters from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework.filterset import FilterSet from drf_spectacular.utils import extend_schema -from rest_framework import status +from rest_framework import permissions, status from rest_framework.exceptions import NotFound from rest_framework.response import Response from rest_framework.views import APIView @@ -174,7 +174,10 @@ class PluginDetail(RetrieveDestroyAPI): queryset = PluginConfig.objects.all() serializer_class = PluginSerializers.PluginConfigSerializer - permission_classes = [InvenTree.permissions.IsSuperuserOrReadOnlyOrScope] + permission_classes = [ + permissions.IsAuthenticated, + InvenTree.permissions.IsSuperuserOrReadOnlyOrScope, + ] lookup_field = 'key' lookup_url_kwarg = 'plugin' @@ -201,6 +204,7 @@ class PluginAdminDetail(RetrieveAPI): queryset = PluginConfig.objects.all() serializer_class = PluginSerializers.PluginAdminDetailSerializer + permission_classes = [InvenTree.permissions.IsAdminOrAdminScope] lookup_field = 'key' lookup_url_kwarg = 'plugin' @@ -292,7 +296,10 @@ class PluginSettingList(ListAPI): queryset = PluginSetting.objects.all() serializer_class = PluginSerializers.PluginSettingSerializer - permission_classes = [InvenTree.permissions.GlobalSettingsPermissions] + permission_classes = [ + permissions.IsAuthenticated, + InvenTree.permissions.GlobalSettingsPermissions, + ] filter_backends = [DjangoFilterBackend] @@ -365,7 +372,10 @@ class PluginAllSettingList(APIView): - GET: return all settings for a plugin config """ - permission_classes = [InvenTree.permissions.GlobalSettingsPermissions] + permission_classes = [ + permissions.IsAuthenticated, + InvenTree.permissions.GlobalSettingsPermissions, + ] @extend_schema( responses={200: PluginSerializers.PluginSettingSerializer(many=True)} @@ -393,6 +403,11 @@ class PluginSettingDetail(RetrieveUpdateAPI): queryset = PluginSetting.objects.all() serializer_class = PluginSerializers.PluginSettingSerializer + permission_classes = [ + permissions.IsAuthenticated, + InvenTree.permissions.GlobalSettingsPermissions, + ] + def get_object(self): """Lookup the plugin setting object, based on the URL. @@ -415,9 +430,6 @@ class PluginSettingDetail(RetrieveUpdateAPI): setting_key, plugin=plugin.plugin_config() ) - # Staff permission required - permission_classes = [InvenTree.permissions.GlobalSettingsPermissions] - class PluginUserSettingList(APIView): """List endpoint for all user settings for a specific plugin. diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index 9e22f40355..8b1c543dd2 100644 --- a/src/backend/InvenTree/plugin/test_api.py +++ b/src/backend/InvenTree/plugin/test_api.py @@ -816,3 +816,48 @@ class PluginLockedSettingsTest(PluginMixin, InvenTreeAPITestCase): self.assertFalse( response.data['read_only'], msg=f'{key} should not be read_only' ) + + +class PluginUnauthenticatedAccessTest(PluginMixin, InvenTreeAPITestCase): + """Ensure plugin API endpoints reject unauthenticated requests. + + Tests the four endpoints hardened on the permissions-fix branch: + - PluginDetail (api-plugin-detail) + - PluginSettingList (api-plugin-setting-list) + - PluginAllSettingList (api-plugin-settings) + - PluginSettingDetail (api-plugin-setting-detail) + """ + + superuser = True + PLUGIN_SLUG = 'sample' + SETTING_KEY = 'API_KEY' + + def setUp(self): + """Activate sample plugin, then log out to simulate an anonymous client.""" + super().setUp() + from plugin.registry import registry + + registry.set_plugin_state(self.PLUGIN_SLUG, True) + self.client.logout() + + def test_plugin_detail_unauthenticated(self): + """GET /api/plugins// must return 401 for unauthenticated users.""" + url = reverse('api-plugin-detail', kwargs={'plugin': self.PLUGIN_SLUG}) + self.get(url, expected_code=401) + + def test_plugin_setting_list_unauthenticated(self): + """GET /api/plugins/settings/ must return 401 for unauthenticated users.""" + self.get(reverse('api-plugin-setting-list'), expected_code=401) + + def test_plugin_all_settings_unauthenticated(self): + """GET /api/plugins//settings/ must return 401 for unauthenticated users.""" + url = reverse('api-plugin-settings', kwargs={'plugin': self.PLUGIN_SLUG}) + self.get(url, expected_code=401) + + def test_plugin_setting_detail_unauthenticated(self): + """GET /api/plugins//settings// must return 401 for unauthenticated users.""" + url = reverse( + 'api-plugin-setting-detail', + kwargs={'plugin': self.PLUGIN_SLUG, 'key': self.SETTING_KEY}, + ) + self.get(url, expected_code=401)