2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-13 06:48:44 +00:00

Merge commit from fork

* Add note to plugin docs.

* Adjust logic for PluginListTable

* Add superuser scope to PluginInstall API endpoint

* Update unit test for API endpoint

* Explicitly set PLUGINS_INSTALL_DISABLED if PLUGINS_ENABLED = False

* Check for superuser permission in installer.py

* Additional user checks

* Sanitize package name to protect against OS command injection
This commit is contained in:
Oliver
2026-04-08 08:16:07 +10:00
committed by GitHub
parent 9c0cb34106
commit b8ec300fbf
8 changed files with 96 additions and 11 deletions

View File

@@ -74,6 +74,9 @@ Enter the package name into the form as shown below. You can add a path and a ve
{{ image("plugin/plugin_install_txt.png", "Plugin.txt file") }} {{ image("plugin/plugin_install_txt.png", "Plugin.txt file") }}
!!! info "Superuser Required"
Only users with superuser privileges can manage plugins via the web interface.
#### Local Directory #### Local Directory
Custom plugins can be placed in the `data/plugins/` directory, where they will be automatically discovered. This can be useful for developing and testing plugins, but can prove more difficult in production (e.g. when using Docker). Custom plugins can be placed in the `data/plugins/` directory, where they will be automatically discovered. This can be useful for developing and testing plugins, but can prove more difficult in production (e.g. when using Docker).

View File

@@ -201,6 +201,11 @@ PLUGINS_INSTALL_DISABLED = get_boolean_setting(
'INVENTREE_PLUGIN_NOINSTALL', 'plugin_noinstall', False 'INVENTREE_PLUGIN_NOINSTALL', 'plugin_noinstall', False
) )
if not PLUGINS_ENABLED:
PLUGINS_INSTALL_DISABLED = (
True # If plugins are disabled, also disable installation
)
PLUGIN_FILE = config.get_plugin_file() PLUGIN_FILE = config.get_plugin_file()
# Plugin test settings # Plugin test settings

View File

@@ -210,6 +210,7 @@ class PluginInstall(CreateAPI):
queryset = PluginConfig.objects.none() queryset = PluginConfig.objects.none()
serializer_class = PluginSerializers.PluginConfigInstallSerializer serializer_class = PluginSerializers.PluginConfigInstallSerializer
permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
"""Install a plugin via the API.""" """Install a plugin via the API."""

View File

@@ -236,8 +236,8 @@ def install_plugin(url=None, packagename=None, user=None, version=None):
user: Optional user performing the installation user: Optional user performing the installation
version: Optional version specifier version: Optional version specifier
""" """
if user and not user.is_staff: if user and not user.is_superuser:
raise ValidationError(_('Only staff users can administer plugins')) raise ValidationError(_('Only superuser accounts can administer plugins'))
if settings.PLUGINS_INSTALL_DISABLED: if settings.PLUGINS_INSTALL_DISABLED:
raise ValidationError(_('Plugin installation is disabled')) raise ValidationError(_('Plugin installation is disabled'))
@@ -269,6 +269,13 @@ def install_plugin(url=None, packagename=None, user=None, version=None):
if version: if version:
full_pkg = f'{full_pkg}=={version}' full_pkg = f'{full_pkg}=={version}'
if not full_pkg:
raise ValidationError(_('No package name or URL provided for installation'))
# Sanitize the package name for installation
if any(c in full_pkg for c in ';&|`$()'):
raise ValidationError(_('Invalid characters in package name or URL'))
install_name.append(full_pkg) install_name.append(full_pkg)
ret = {} ret = {}
@@ -333,6 +340,9 @@ def uninstall_plugin(cfg: plugin.models.PluginConfig, user=None, delete_config=T
""" """
from plugin.registry import registry from plugin.registry import registry
if user and not user.is_superuser:
raise ValidationError(_('Only superuser accounts can administer plugins'))
if settings.PLUGINS_INSTALL_DISABLED: if settings.PLUGINS_INSTALL_DISABLED:
raise ValidationError(_('Plugin uninstalling is disabled')) raise ValidationError(_('Plugin uninstalling is disabled'))

View File

@@ -165,6 +165,9 @@ class PluginConfigInstallSerializer(serializers.Serializer):
version = data.get('version', None) version = data.get('version', None)
user = self.context['request'].user user = self.context['request'].user
if not user or not user.is_superuser:
raise ValidationError(_('Only superuser accounts can administer plugins'))
return install_plugin( return install_plugin(
url=url, packagename=packagename, version=version, user=user url=url, packagename=packagename, version=version, user=user
) )
@@ -266,10 +269,13 @@ class PluginUninstallSerializer(serializers.Serializer):
"""Uninstall the specified plugin.""" """Uninstall the specified plugin."""
from plugin.installer import uninstall_plugin from plugin.installer import uninstall_plugin
user = self.context['request'].user
if not user or not user.is_superuser:
raise ValidationError(_('Only superuser accounts can administer plugins'))
return uninstall_plugin( return uninstall_plugin(
instance, instance, user=user, delete_config=validated_data.get('delete_config', True)
user=self.context['request'].user,
delete_config=validated_data.get('delete_config', True),
) )

View File

@@ -63,6 +63,21 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
"""Test the plugin install command.""" """Test the plugin install command."""
url = reverse('api-plugin-install') url = reverse('api-plugin-install')
# Requires superuser permissions
self.user.is_superuser = False
self.user.save()
self.post(
url,
{'confirm': True, 'packagename': self.PKG_NAME},
expected_code=403,
max_query_time=30,
)
# Provide superuser permissions
self.user.is_superuser = True
self.user.save()
# invalid package name # invalid package name
data = self.post( data = self.post(
url, url,
@@ -209,7 +224,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
test_plg.refresh_from_db() test_plg.refresh_from_db()
self.assertTrue(test_plg.is_active()) self.assertTrue(test_plg.is_active())
def test_pluginCfg_delete(self): def test_plugin_config_delete(self):
"""Test deleting a config.""" """Test deleting a config."""
test_plg = self.plugin_confs.first() test_plg = self.plugin_confs.first()
assert test_plg is not None assert test_plg is not None

View File

@@ -11,11 +11,14 @@ from typing import Optional
from unittest import mock from unittest import mock
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
import plugin.templatetags.plugin_extras as plugin_tags import plugin.templatetags.plugin_extras as plugin_tags
from InvenTree.unit_test import PluginRegistryMixin, TestQueryMixin from InvenTree.unit_test import PluginRegistryMixin, TestQueryMixin
from plugin import InvenTreePlugin, PluginMixinEnum from plugin import InvenTreePlugin, PluginMixinEnum
from plugin.installer import install_plugin
from plugin.registry import registry from plugin.registry import registry
from plugin.samples.integration.another_sample import ( from plugin.samples.integration.another_sample import (
NoIntegrationPlugin, NoIntegrationPlugin,
@@ -326,6 +329,9 @@ class RegistryTests(TestQueryMixin, PluginRegistryMixin, TestCase):
def test_broken_samples(self): def test_broken_samples(self):
"""Test that the broken samples trigger reloads.""" """Test that the broken samples trigger reloads."""
# Reset the registry to a known state
registry.errors = {}
# In the base setup there are no errors # In the base setup there are no errors
self.assertEqual(len(registry.errors), 0) self.assertEqual(len(registry.errors), 0)
@@ -686,3 +692,40 @@ class RegistryTests(TestQueryMixin, PluginRegistryMixin, TestCase):
self.assertTrue(cfg.is_builtin()) self.assertTrue(cfg.is_builtin())
self.assertFalse(cfg.is_package()) self.assertFalse(cfg.is_package())
self.assertFalse(cfg.is_sample()) self.assertFalse(cfg.is_sample())
class InstallerTests(TestCase):
"""Tests for the plugin installer code."""
def test_plugin_install_errors(self):
"""Test error handling for plugin installation."""
# No data provided
with self.assertRaises(ValidationError) as e:
install_plugin()
self.assertIn(
'No package name or URL provided for installation', str(e.exception)
)
# Invalid package name
for pkg in [
'invalid;name',
'invalid&name',
'invalid|name',
'invalid`name',
'invalid$(name)',
]:
with self.assertRaises(ValidationError) as e:
install_plugin(packagename=pkg)
self.assertIn('Invalid characters in package name or URL', str(e.exception))
# Non superuser account
user = User.objects.create(username='my-user', is_superuser=False)
with self.assertRaises(ValidationError) as e:
install_plugin(user=user, packagename='some-package')
self.assertIn(
'Only superuser accounts can administer plugins', str(e.exception)
)

View File

@@ -220,7 +220,6 @@ export default function PluginListTable() {
// Uninstall an installed plugin // Uninstall an installed plugin
// Must be inactive, not a builtin, not a sample, and installed as a package // Must be inactive, not a builtin, not a sample, and installed as a package
hidden: hidden:
!user.isSuperuser() ||
record.active || record.active ||
record.is_builtin || record.is_builtin ||
record.is_mandatory || record.is_mandatory ||
@@ -244,8 +243,7 @@ export default function PluginListTable() {
record.is_builtin || record.is_builtin ||
record.is_mandatory || record.is_mandatory ||
record.is_sample || record.is_sample ||
record.is_installed || record.is_installed,
!user.isSuperuser(),
title: t`Delete`, title: t`Delete`,
tooltip: t`Delete selected plugin configuration`, tooltip: t`Delete selected plugin configuration`,
color: 'red', color: 'red',
@@ -355,7 +353,12 @@ export default function PluginListTable() {
// Custom table actions // Custom table actions
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
if (!user.isSuperuser() || !server.plugins_enabled) { if (
!user.isSuperuser() ||
!server.plugins_enabled ||
server.plugins_install_disabled
) {
// Prevent installation if plugins are disabled or user is not superuser
return []; return [];
} }
@@ -376,7 +379,6 @@ export default function PluginListTable() {
setPluginPackage(''); setPluginPackage('');
installPluginModal.open(); installPluginModal.open();
}} }}
disabled={server.plugins_install_disabled || false}
/> />
]; ];
}, [user, server]); }, [user, server]);