From 8b464e43971328b97d36378dab8720173a9de745 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 12 Jun 2022 16:06:11 +1000 Subject: [PATCH 1/8] Migrate "Convert to Variant" form to the API (#3183) * Adds a Part API filter to limit query to valid conversion options for the specified part * Refactor 'exclude_tree' filter to use django-filter framework * Refactor the 'ancestor' filter * Refactoring more API filtering fields: - variant_of - in_bom_for * Adds API endpoint / view / serializer for converting a StockItem to variant * stock item conversion now perfomed via the API * Bump API version * Add unit tests for new filtering option on the Part list API endpoint * Adds unit test for "convert" API endpoint functionality --- InvenTree/InvenTree/api_version.py | 6 +- InvenTree/part/api.py | 102 ++++++++---------- InvenTree/part/test_api.py | 58 ++++++++++ InvenTree/stock/api.py | 7 ++ InvenTree/stock/forms.py | 20 ---- InvenTree/stock/serializers.py | 40 +++++++ .../stock/templates/stock/item_base.html | 24 ++++- .../templates/stock/stockitem_convert.html | 17 --- InvenTree/stock/test_api.py | 63 +++++++++++ InvenTree/stock/urls.py | 1 - InvenTree/stock/views.py | 32 +----- 11 files changed, 244 insertions(+), 126 deletions(-) delete mode 100644 InvenTree/stock/forms.py delete mode 100644 InvenTree/stock/templates/stock/stockitem_convert.html diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 2daa9e3da7..b7ce609491 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,15 @@ # InvenTree API version -INVENTREE_API_VERSION = 60 +INVENTREE_API_VERSION = 61 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v61 -> 2022-06-12 : https://github.com/inventree/InvenTree/pull/3183 + - Migrate the "Convert Stock Item" form class to use the API + - There is now an API endpoint for converting a stock item to a valid variant + v60 -> 2022-06-08 : https://github.com/inventree/InvenTree/pull/3148 - Add availability data fields to the SupplierPart model diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 23b2955744..7f9b0d150d 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -810,6 +810,53 @@ class PartFilter(rest_filters.FilterSet): return queryset + convert_from = rest_filters.ModelChoiceFilter(label="Can convert from", queryset=Part.objects.all(), method='filter_convert_from') + + def filter_convert_from(self, queryset, name, part): + """Limit the queryset to valid conversion options for the specified part""" + conversion_options = part.get_conversion_options() + + queryset = queryset.filter(pk__in=conversion_options) + + return queryset + + exclude_tree = rest_filters.ModelChoiceFilter(label="Exclude Part tree", queryset=Part.objects.all(), method='filter_exclude_tree') + + def filter_exclude_tree(self, queryset, name, part): + """Exclude all parts and variants 'down' from the specified part from the queryset""" + + children = part.get_descendants(include_self=True) + + queryset = queryset.exclude(id__in=children) + + return queryset + + ancestor = rest_filters.ModelChoiceFilter(label='Ancestor', queryset=Part.objects.all(), method='filter_ancestor') + + def filter_ancestor(self, queryset, name, part): + """Limit queryset to descendants of the specified ancestor part""" + + descendants = part.get_descendants(include_self=False) + queryset = queryset.filter(id__in=descendants) + + return queryset + + variant_of = rest_filters.ModelChoiceFilter(label='Variant Of', queryset=Part.objects.all(), method='filter_variant_of') + + def filter_variant_of(self, queryset, name, part): + """Limit queryset to direct children (variants) of the specified part""" + + queryset = queryset.filter(id__in=part.get_children()) + return queryset + + in_bom_for = rest_filters.ModelChoiceFilter(label='In BOM Of', queryset=Part.objects.all(), method='filter_in_bom') + + def filter_in_bom(self, queryset, name, part): + """Limit queryset to parts in the BOM for the specified part""" + + queryset = queryset.filter(id__in=part.get_parts_in_bom()) + return queryset + is_template = rest_filters.BooleanFilter() assembly = rest_filters.BooleanFilter() @@ -1129,61 +1176,6 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView): queryset = queryset.exclude(pk__in=id_values) - # Exclude part variant tree? - exclude_tree = params.get('exclude_tree', None) - - if exclude_tree is not None: - try: - top_level_part = Part.objects.get(pk=exclude_tree) - - queryset = queryset.exclude( - pk__in=[prt.pk for prt in top_level_part.get_descendants(include_self=True)] - ) - - except (ValueError, Part.DoesNotExist): - pass - - # Filter by 'ancestor'? - ancestor = params.get('ancestor', None) - - if ancestor is not None: - # If an 'ancestor' part is provided, filter to match only children - try: - ancestor = Part.objects.get(pk=ancestor) - descendants = ancestor.get_descendants(include_self=False) - queryset = queryset.filter(pk__in=[d.pk for d in descendants]) - except (ValueError, Part.DoesNotExist): - pass - - # Filter by 'variant_of' - # Note that this is subtly different from 'ancestor' filter (above) - variant_of = params.get('variant_of', None) - - if variant_of is not None: - try: - template = Part.objects.get(pk=variant_of) - variants = template.get_children() - queryset = queryset.filter(pk__in=[v.pk for v in variants]) - except (ValueError, Part.DoesNotExist): - pass - - # Filter only parts which are in the "BOM" for a given part - in_bom_for = params.get('in_bom_for', None) - - if in_bom_for is not None: - try: - in_bom_for = Part.objects.get(pk=in_bom_for) - - # Extract a list of parts within the BOM - bom_parts = in_bom_for.get_parts_in_bom() - print("bom_parts:", bom_parts) - print([p.pk for p in bom_parts]) - - queryset = queryset.filter(pk__in=[p.pk for p in bom_parts]) - - except (ValueError, Part.DoesNotExist): - pass - # Filter by whether the BOM has been validated (or not) bom_valid = params.get('bom_valid', None) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 1c8aa694d3..3d2c0d8a06 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -391,6 +391,64 @@ class PartAPITest(InvenTreeAPITestCase): response = self.get(url, {'related': 1}, expected_code=200) self.assertEqual(len(response.data), 2) + def test_filter_by_convert(self): + """Test that we can correctly filter the Part list by conversion options""" + + category = PartCategory.objects.get(pk=3) + + # First, construct a set of template / variant parts + master_part = Part.objects.create( + name='Master', description='Master part', + category=category, + is_template=True, + ) + + # Construct a set of variant parts + variants = [] + + for color in ['Red', 'Green', 'Blue', 'Yellow', 'Pink', 'Black']: + variants.append(Part.objects.create( + name=f"{color} Variant", description="Variant part with a specific color", + variant_of=master_part, + category=category, + )) + + url = reverse('api-part-list') + + # An invalid part ID will return an error + response = self.get( + url, + { + 'convert_from': 999999, + }, + expected_code=400 + ) + + self.assertIn('Select a valid choice', str(response.data['convert_from'])) + + for variant in variants: + response = self.get( + url, + { + 'convert_from': variant.pk, + }, + expected_code=200 + ) + + # There should be the same number of results for each request + self.assertEqual(len(response.data), 6) + + id_values = [p['pk'] for p in response.data] + + self.assertIn(master_part.pk, id_values) + + for v in variants: + # Check that all *other* variants are included also + if v == variant: + continue + + self.assertIn(v.pk, id_values) + def test_include_children(self): """Test the special 'include_child_categories' flag. diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 13b3c0219e..e3fe14d6b4 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -129,6 +129,12 @@ class StockItemUninstall(StockItemContextMixin, generics.CreateAPIView): serializer_class = StockSerializers.UninstallStockItemSerializer +class StockItemConvert(StockItemContextMixin, generics.CreateAPIView): + """API endpoint for converting a stock item to a variant part""" + + serializer_class = StockSerializers.ConvertStockItemSerializer + + class StockItemReturn(StockItemContextMixin, generics.CreateAPIView): """API endpoint for returning a stock item from a customer""" @@ -1374,6 +1380,7 @@ stock_api_urls = [ # Detail views for a single stock item re_path(r'^(?P\d+)/', include([ + re_path(r'^convert/', StockItemConvert.as_view(), name='api-stock-item-convert'), re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'), re_path(r'^metadata/', StockMetadata.as_view(), name='api-stock-item-metadata'), re_path(r'^return/', StockItemReturn.as_view(), name='api-stock-item-return'), diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py deleted file mode 100644 index e091730deb..0000000000 --- a/InvenTree/stock/forms.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Django Forms for interacting with Stock app.""" - -from InvenTree.forms import HelperForm - -from .models import StockItem - - -class ConvertStockItemForm(HelperForm): - """Form for converting a StockItem to a variant of its current part. - - TODO: Migrate this form to the modern API forms interface - """ - - class Meta: - """Metaclass options.""" - - model = StockItem - fields = [ - 'part' - ] diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index aebc102e79..a5a39ab146 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -17,6 +17,7 @@ import common.models import company.models import InvenTree.helpers import InvenTree.serializers +import part.models as part_models from common.settings import currency_code_default, currency_code_mappings from company.serializers import SupplierPartSerializer from InvenTree.serializers import InvenTreeDecimalField, extract_int @@ -464,6 +465,45 @@ class UninstallStockItemSerializer(serializers.Serializer): ) +class ConvertStockItemSerializer(serializers.Serializer): + """DRF serializer class for converting a StockItem to a valid variant part""" + + class Meta: + """Metaclass options""" + fields = [ + 'part', + ] + + part = serializers.PrimaryKeyRelatedField( + queryset=part_models.Part.objects.all(), + label=_('Part'), + help_text=_('Select part to convert stock item into'), + many=False, required=True, allow_null=False + ) + + def validate_part(self, part): + """Ensure that the provided part is a valid option for the stock item""" + + stock_item = self.context['item'] + valid_options = stock_item.part.get_conversion_options() + + if part not in valid_options: + raise ValidationError(_("Selected part is not a valid option for conversion")) + + return part + + def save(self): + """Save the serializer to convert the StockItem to the selected Part""" + data = self.validated_data + + part = data['part'] + + stock_item = self.context['item'] + request = self.context['request'] + + stock_item.convert_to_variant(part, request.user) + + class ReturnStockItemSerializer(serializers.Serializer): """DRF serializer for returning a stock item from a customer""" diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 19964e8b29..ff8c7687b7 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -588,9 +588,31 @@ $("#stock-delete").click(function () { {% if item.part.can_convert %} $("#stock-convert").click(function() { - launchModalForm("{% url 'stock-item-convert' item.id %}", + + var html = ` +
+ {% trans "Select one of the part variants listed below." %} +
+
+ {% trans "Warning" %} + {% trans "This action cannot be easily undone" %} +
+ `; + + constructForm( + '{% url "api-stock-item-convert" item.pk %}', { + method: 'POST', + title: '{% trans "Convert Stock Item" %}', + preFormContent: html, reload: true, + fields: { + part: { + filters: { + convert_from: {{ item.part.pk }} + } + }, + } } ); }); diff --git a/InvenTree/stock/templates/stock/stockitem_convert.html b/InvenTree/stock/templates/stock/stockitem_convert.html deleted file mode 100644 index 90c3fd8e1e..0000000000 --- a/InvenTree/stock/templates/stock/stockitem_convert.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} - -
- {% trans "Convert Stock Item" %}
- {% blocktrans with part=item.part %}This stock item is current an instance of {{part}}{% endblocktrans %}
- {% trans "It can be converted to one of the part variants listed below." %} -
- -
- {% trans "Warning" %} - {% trans "This action cannot be easily undone" %} -
- -{% endblock %} diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 93f35b78e1..e35eabc7df 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -702,6 +702,69 @@ class StockItemTest(StockAPITestCase): # The item is now in stock self.assertIsNone(item.customer) + def test_convert_to_variant(self): + """Test that we can convert a StockItem to a variant part via the API""" + + category = part.models.PartCategory.objects.get(pk=3) + + # First, construct a set of template / variant parts + master_part = part.models.Part.objects.create( + name='Master', description='Master part', + category=category, + is_template=True, + ) + + variants = [] + + # Construct a set of variant parts + for color in ['Red', 'Green', 'Blue', 'Yellow', 'Pink', 'Black']: + variants.append(part.models.Part.objects.create( + name=f"{color} Variant", description="Variant part with a specific color", + variant_of=master_part, + category=category, + )) + + stock_item = StockItem.objects.create( + part=master_part, + quantity=1000, + ) + + url = reverse('api-stock-item-convert', kwargs={'pk': stock_item.pk}) + + # Attempt to convert to a part which does not exist + response = self.post( + url, + { + 'part': 999999, + }, + expected_code=400, + ) + + self.assertIn('object does not exist', str(response.data['part'])) + + # Attempt to convert to a part which is not a valid option + response = self.post( + url, + { + 'part': 1, + }, + expected_code=400 + ) + + self.assertIn('Selected part is not a valid option', str(response.data['part'])) + + for variant in variants: + response = self.post( + url, + { + 'part': variant.pk, + }, + expected_code=201, + ) + + stock_item.refresh_from_db() + self.assertEqual(stock_item.part, variant) + class StocktakeTest(StockAPITestCase): """Series of tests for the Stocktake API.""" diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 6c4eec8d7e..b61bd8eb60 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -16,7 +16,6 @@ location_urls = [ ] stock_item_detail_urls = [ - re_path(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'), re_path(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'), # Anything else - direct to the item detail view diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 267b8734d1..6177972259 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -6,10 +6,9 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import DetailView, ListView import common.settings -from InvenTree.views import AjaxUpdateView, InvenTreeRoleMixin, QRCodeView +from InvenTree.views import InvenTreeRoleMixin, QRCodeView from plugin.views import InvenTreePluginViewMixin -from . import forms as StockForms from .models import StockItem, StockLocation @@ -133,32 +132,3 @@ class StockItemQRCode(QRCodeView): return item.format_barcode() except StockItem.DoesNotExist: return None - - -class StockItemConvert(AjaxUpdateView): - """View for 'converting' a StockItem to a variant of its current part.""" - - model = StockItem - form_class = StockForms.ConvertStockItemForm - ajax_form_title = _('Convert Stock Item') - ajax_template_name = 'stock/stockitem_convert.html' - context_object_name = 'item' - - def get_form(self): - """Filter the available parts.""" - form = super().get_form() - item = self.get_object() - - form.fields['part'].queryset = item.part.get_conversion_options() - - return form - - def save(self, obj, form): - """Convert item to variant.""" - stock_item = self.get_object() - - variant = form.cleaned_data.get('part', None) - - stock_item.convert_to_variant(variant, user=self.request.user) - - return stock_item From 0a0d151f15f4869db7b887b4c076b3e3d3baace8 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 14 Jun 2022 08:09:51 +1000 Subject: [PATCH 2/8] Add security.md (#3190) * Create SECURITY.md Add a security disclosure policty document (cherry picked from commit 35b7d51cf20b2e80ddbb7a337e8ab472a6f36300) * Adds desired target for resolution (cherry picked from commit 828163848aedd40d5007f1830fcd0fc800647841) --- SECURITY.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..a05e6e8701 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Security Policy + +The InvenTree team take all security vulnerabilities seriously. Thank you for improving the security of our open source software. +We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions. + +## Reporting a Vulnerability + +Please report security vulnerabilities by emailing the InvenTree team at: + +``` +security@inventree.org +``` + +Someone from the InvenTree development team will acknowledge your email as soon as possible, and indicate the next steps in handling your security report. + + +The team will endeavour to keep you informed of the progress towards a fix for the issue, and subsequent release to the stable and development code branches. Where possible, the issue will be resolved within 90 dates of reporting. From 3ae0a9d9749504f8e4bb80a7efe91a59fd5c58a3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 14 Jun 2022 08:10:10 +1000 Subject: [PATCH 3/8] Add major release notes section for security fixes (#3191) - Ref: https://github.com/inventree/InvenTree/pull/3190 --- .github/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/release.yml b/.github/release.yml index d691460313..34562cd9b7 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -9,6 +9,9 @@ changelog: labels: - Semver-Major - breaking + - title: Security Patches + labels: + - security - title: New Features labels: - Semver-Minor @@ -23,7 +26,6 @@ changelog: - setup - demo - CI - - security - title: Other Changes labels: - "*" From 0759c3769e68035f60925e907a98f30f1e380bc7 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 14 Jun 2022 10:07:48 +1000 Subject: [PATCH 4/8] Spelling fix: dates -> days (#3193) --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index a05e6e8701..6054604914 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,4 +14,4 @@ security@inventree.org Someone from the InvenTree development team will acknowledge your email as soon as possible, and indicate the next steps in handling your security report. -The team will endeavour to keep you informed of the progress towards a fix for the issue, and subsequent release to the stable and development code branches. Where possible, the issue will be resolved within 90 dates of reporting. +The team will endeavour to keep you informed of the progress towards a fix for the issue, and subsequent release to the stable and development code branches. Where possible, the issue will be resolved within 90 days of reporting. From 76aa3a75f2e5b93877a229e29326b8b4ea815aea Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 15 Jun 2022 18:31:56 +1000 Subject: [PATCH 5/8] Merge pull request from GHSA-fr2w-mp56-g4xp * Enforce file download for attachments table(s) * Enforce file download for attachment in 'StockItemTestResult' table --- InvenTree/templates/js/translated/attachment.js | 2 +- InvenTree/templates/js/translated/stock.js | 3 ++- InvenTree/templates/js/translated/tables.js | 9 ++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/InvenTree/templates/js/translated/attachment.js b/InvenTree/templates/js/translated/attachment.js index 0dc3f438af..991d3efeba 100644 --- a/InvenTree/templates/js/translated/attachment.js +++ b/InvenTree/templates/js/translated/attachment.js @@ -228,7 +228,7 @@ function loadAttachmentTable(url, options) { var html = ` ${filename}`; - return renderLink(html, value); + return renderLink(html, value, {download: true}); } else if (row.link) { var html = ` ${row.link}`; return renderLink(html, row.link); diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 825e1a8094..35de58dd97 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1358,7 +1358,8 @@ function loadStockTestResultsTable(table, options) { var html = value; if (row.attachment) { - html += ``; + var text = ``; + html += renderLink(text, row.attachment, {download: true}); } return html; diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index fcbaba7336..952806bce2 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -184,6 +184,13 @@ function renderLink(text, url, options={}) { var max_length = options.max_length || -1; + var extra = ''; + + if (options.download) { + var fn = url.split('/').at(-1); + extra += ` download='${fn}'`; + } + // Shorten the displayed length if required if ((max_length > 0) && (text.length > max_length)) { var slice_length = (max_length - 3) / 2; @@ -194,7 +201,7 @@ function renderLink(text, url, options={}) { text = `${text_start}...${text_end}`; } - return '' + text + ''; + return `${text}`; } From 57563f6b7acd1dbff69f7e519b244ed33d957a3d Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 15 Jun 2022 18:32:35 +1000 Subject: [PATCH 6/8] Merge pull request from GHSA-7rq4-qcpw-74gq * Create custom ModelResource subclass - Strips illegal starting characters from string cells - Prevents formula injection * Update all existing ModelResource classes to base off InvenTreeResource * Handle more complex case where an illegal char is hidden behind another one --- InvenTree/InvenTree/admin.py | 33 +++++++++++++++++++++++++++++++++ InvenTree/build/admin.py | 5 ++--- InvenTree/company/admin.py | 12 ++++++------ InvenTree/order/admin.py | 15 ++++++++------- InvenTree/part/admin.py | 10 +++++----- InvenTree/stock/admin.py | 6 +++--- 6 files changed, 57 insertions(+), 24 deletions(-) create mode 100644 InvenTree/InvenTree/admin.py diff --git a/InvenTree/InvenTree/admin.py b/InvenTree/InvenTree/admin.py new file mode 100644 index 0000000000..2d5798a9d1 --- /dev/null +++ b/InvenTree/InvenTree/admin.py @@ -0,0 +1,33 @@ +"""Admin classes""" + +from import_export.resources import ModelResource + + +class InvenTreeResource(ModelResource): + """Custom subclass of the ModelResource class provided by django-import-export" + + Ensures that exported data are escaped to prevent malicious formula injection. + Ref: https://owasp.org/www-community/attacks/CSV_Injection + """ + + def export_resource(self, obj): + """Custom function to override default row export behaviour. + + Specifically, strip illegal leading characters to prevent formula injection + """ + row = super().export_resource(obj) + + illegal_start_vals = ['@', '=', '+', '-', '@', '\t', '\r', '\n'] + + for idx, val in enumerate(row): + if type(val) is str: + val = val.strip() + + # If the value starts with certain 'suspicious' values, remove it! + while len(val) > 0 and val[0] in illegal_start_vals: + # Remove the first character + val = val[1:] + + row[idx] = val + + return row diff --git a/InvenTree/build/admin.py b/InvenTree/build/admin.py index eec7376ede..6f203d071b 100644 --- a/InvenTree/build/admin.py +++ b/InvenTree/build/admin.py @@ -4,15 +4,14 @@ from django.contrib import admin from import_export.admin import ImportExportModelAdmin from import_export.fields import Field -from import_export.resources import ModelResource import import_export.widgets as widgets from build.models import Build, BuildItem - +from InvenTree.admin import InvenTreeResource import part.models -class BuildResource(ModelResource): +class BuildResource(InvenTreeResource): """Class for managing import/export of Build data.""" # For some reason, we need to specify the fields individually for this ModelResource, # but we don't for other ones. diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py index d279c9227d..11e9a2720e 100644 --- a/InvenTree/company/admin.py +++ b/InvenTree/company/admin.py @@ -5,8 +5,8 @@ from django.contrib import admin import import_export.widgets as widgets from import_export.admin import ImportExportModelAdmin from import_export.fields import Field -from import_export.resources import ModelResource +from InvenTree.admin import InvenTreeResource from part.models import Part from .models import (Company, ManufacturerPart, ManufacturerPartAttachment, @@ -14,7 +14,7 @@ from .models import (Company, ManufacturerPart, ManufacturerPartAttachment, SupplierPriceBreak) -class CompanyResource(ModelResource): +class CompanyResource(InvenTreeResource): """Class for managing Company data import/export.""" class Meta: @@ -38,7 +38,7 @@ class CompanyAdmin(ImportExportModelAdmin): ] -class SupplierPartResource(ModelResource): +class SupplierPartResource(InvenTreeResource): """Class for managing SupplierPart data import/export.""" part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) @@ -74,7 +74,7 @@ class SupplierPartAdmin(ImportExportModelAdmin): autocomplete_fields = ('part', 'supplier', 'manufacturer_part',) -class ManufacturerPartResource(ModelResource): +class ManufacturerPartResource(InvenTreeResource): """Class for managing ManufacturerPart data import/export.""" part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) @@ -117,7 +117,7 @@ class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin): autocomplete_fields = ('manufacturer_part',) -class ManufacturerPartParameterResource(ModelResource): +class ManufacturerPartParameterResource(InvenTreeResource): """Class for managing ManufacturerPartParameter data import/export.""" class Meta: @@ -144,7 +144,7 @@ class ManufacturerPartParameterAdmin(ImportExportModelAdmin): autocomplete_fields = ('manufacturer_part',) -class SupplierPriceBreakResource(ModelResource): +class SupplierPriceBreakResource(InvenTreeResource): """Class for managing SupplierPriceBreak data import/export.""" part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart)) diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index be953de701..aa24c095f6 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -5,7 +5,8 @@ from django.contrib import admin import import_export.widgets as widgets from import_export.admin import ImportExportModelAdmin from import_export.fields import Field -from import_export.resources import ModelResource + +from InvenTree.admin import InvenTreeResource from .models import (PurchaseOrder, PurchaseOrderExtraLine, PurchaseOrderLineItem, SalesOrder, SalesOrderAllocation, @@ -97,7 +98,7 @@ class SalesOrderAdmin(ImportExportModelAdmin): autocomplete_fields = ('customer',) -class PurchaseOrderResource(ModelResource): +class PurchaseOrderResource(InvenTreeResource): """Class for managing import / export of PurchaseOrder data.""" # Add number of line items @@ -116,7 +117,7 @@ class PurchaseOrderResource(ModelResource): ] -class PurchaseOrderLineItemResource(ModelResource): +class PurchaseOrderLineItemResource(InvenTreeResource): """Class for managing import / export of PurchaseOrderLineItem data.""" part_name = Field(attribute='part__part__name', readonly=True) @@ -135,7 +136,7 @@ class PurchaseOrderLineItemResource(ModelResource): clean_model_instances = True -class PurchaseOrderExtraLineResource(ModelResource): +class PurchaseOrderExtraLineResource(InvenTreeResource): """Class for managing import / export of PurchaseOrderExtraLine data.""" class Meta(GeneralExtraLineMeta): @@ -144,7 +145,7 @@ class PurchaseOrderExtraLineResource(ModelResource): model = PurchaseOrderExtraLine -class SalesOrderResource(ModelResource): +class SalesOrderResource(InvenTreeResource): """Class for managing import / export of SalesOrder data.""" # Add number of line items @@ -163,7 +164,7 @@ class SalesOrderResource(ModelResource): ] -class SalesOrderLineItemResource(ModelResource): +class SalesOrderLineItemResource(InvenTreeResource): """Class for managing import / export of SalesOrderLineItem data.""" part_name = Field(attribute='part__name', readonly=True) @@ -192,7 +193,7 @@ class SalesOrderLineItemResource(ModelResource): clean_model_instances = True -class SalesOrderExtraLineResource(ModelResource): +class SalesOrderExtraLineResource(InvenTreeResource): """Class for managing import / export of SalesOrderExtraLine data.""" class Meta(GeneralExtraLineMeta): diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index 9c1648e616..bf4ae571f5 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -5,14 +5,14 @@ from django.contrib import admin import import_export.widgets as widgets from import_export.admin import ImportExportModelAdmin from import_export.fields import Field -from import_export.resources import ModelResource import part.models as models from company.models import SupplierPart +from InvenTree.admin import InvenTreeResource from stock.models import StockLocation -class PartResource(ModelResource): +class PartResource(InvenTreeResource): """Class for managing Part data import/export.""" # ForeignKey fields @@ -92,7 +92,7 @@ class PartAdmin(ImportExportModelAdmin): ] -class PartCategoryResource(ModelResource): +class PartCategoryResource(InvenTreeResource): """Class for managing PartCategory data import/export.""" parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory)) @@ -157,7 +157,7 @@ class PartTestTemplateAdmin(admin.ModelAdmin): autocomplete_fields = ('part',) -class BomItemResource(ModelResource): +class BomItemResource(InvenTreeResource): """Class for managing BomItem data import/export.""" level = Field(attribute='level', readonly=True) @@ -266,7 +266,7 @@ class ParameterTemplateAdmin(ImportExportModelAdmin): search_fields = ('name', 'units') -class ParameterResource(ModelResource): +class ParameterResource(InvenTreeResource): """Class for managing PartParameter data import/export.""" part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part)) diff --git a/InvenTree/stock/admin.py b/InvenTree/stock/admin.py index f3c56553c5..270281ae3e 100644 --- a/InvenTree/stock/admin.py +++ b/InvenTree/stock/admin.py @@ -5,10 +5,10 @@ from django.contrib import admin import import_export.widgets as widgets from import_export.admin import ImportExportModelAdmin from import_export.fields import Field -from import_export.resources import ModelResource from build.models import Build from company.models import Company, SupplierPart +from InvenTree.admin import InvenTreeResource from order.models import PurchaseOrder, SalesOrder from part.models import Part @@ -16,7 +16,7 @@ from .models import (StockItem, StockItemAttachment, StockItemTestResult, StockItemTracking, StockLocation) -class LocationResource(ModelResource): +class LocationResource(InvenTreeResource): """Class for managing StockLocation data import/export.""" parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(StockLocation)) @@ -68,7 +68,7 @@ class LocationAdmin(ImportExportModelAdmin): ] -class StockItemResource(ModelResource): +class StockItemResource(InvenTreeResource): """Class for managing StockItem data import/export.""" # Custom managers for ForeignKey fields From cd418d6948e6bf5f428cec5b4a7a1f0618a482a3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 15 Jun 2022 18:33:33 +1000 Subject: [PATCH 7/8] Merge pull request from GHSA-rm89-9g65-4ffr * Enable HTML escaping for all tables by default * Enable HTML escaping for all tables by default * Adds automatic escaping for bootstrap tables where custom formatter function is specified - Intercept the row data *before* it is provided to the renderer function - Adds a function for sanitizing nested data structure * Sanitize form data before processing --- .../static/script/inventree/inventree.js | 35 +++++++++++++++ InvenTree/templates/js/translated/forms.js | 3 ++ InvenTree/templates/js/translated/tables.js | 45 +++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index 7999e4a7be..92239cec86 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -13,6 +13,7 @@ inventreeDocReady, inventreeLoad, inventreeSave, + sanitizeData, */ function attachClipboard(selector, containerselector, textElement) { @@ -273,6 +274,40 @@ function loadBrandIcon(element, name) { } } + +/* + * Function to sanitize a (potentially nested) object. + * Iterates through all levels, and sanitizes each primitive string. + * + * Note that this function effectively provides a "deep copy" of the provided data, + * and the original data structure is unaltered. + */ +function sanitizeData(data) { + if (data == null) { + return null; + } else if (Array.isArray(data)) { + // Handle arrays + var ret = []; + data.forEach(function(val) { + ret.push(sanitizeData(val)); + }); + } else if (typeof(data) === 'object') { + // Handle nested structures + var nested = {}; + $.each(data, function(k, v) { + nested[k] = sanitizeData(v); + }); + + return nested; + } else if (typeof(data) === 'string') { + // Perform string replacement + return data.replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/`/g, '`'); + } else { + return data; + } +} + + // Convenience function to determine if an element exists $.fn.exists = function() { return this.length !== 0; diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 9f536fd548..fb9f422b67 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -204,6 +204,9 @@ function constructChangeForm(fields, options) { }, success: function(data) { + // Ensure the data are fully sanitized before we operate on it + data = sanitizeData(data); + // An optional function can be provided to process the returned results, // before they are rendered to the form if (options.processResults) { diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index 952806bce2..b65d46b283 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -381,6 +381,8 @@ $.fn.inventreeTable = function(options) { // Extract query params var filters = options.queryParams || options.filters || {}; + options.escape = true; + // Store the total set of query params options.query_params = filters; @@ -567,6 +569,49 @@ function customGroupSorter(sortName, sortOrder, sortData) { $.extend($.fn.bootstrapTable.defaults, $.fn.bootstrapTable.locales['en-US-custom']); + // Enable HTML escaping by default + $.fn.bootstrapTable.escape = true; + + // Override the 'calculateObjectValue' function at bootstrap-table.js:3525 + // Allows us to escape any nasty HTML tags which are rendered to the DOM + $.fn.bootstrapTable.utils._calculateObjectValue = $.fn.bootstrapTable.utils.calculateObjectValue; + + $.fn.bootstrapTable.utils.calculateObjectValue = function escapeCellValue(self, name, args, defaultValue) { + + var args_list = []; + + if (args) { + + args_list.push(args[0]); + + if (name && typeof(name) === 'function' && name.name == 'formatter') { + /* This is a custom "formatter" function for a particular cell, + * which may side-step regular HTML escaping, and inject malicious code into the DOM. + * + * Here we have access to the 'args' supplied to the custom 'formatter' function, + * which are in the order: + * args = [value, row, index, field] + * + * 'row' is the one we are interested in + */ + + var row = Object.assign({}, args[1]); + + args_list.push(sanitizeData(row)); + } else { + args_list.push(args[1]); + } + + for (var ii = 2; ii < args.length; ii++) { + args_list.push(args[ii]); + } + } + + var value = $.fn.bootstrapTable.utils._calculateObjectValue(self, name, args_list, defaultValue); + + return value; + }; + })(jQuery); $.extend($.fn.treegrid.defaults, { From 7a1869d30cf7020953ee2c3e244020c6b7fb283c Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 15 Jun 2022 20:43:32 +1000 Subject: [PATCH 8/8] Fix sanitization for array case - was missing a return value (#3199) (cherry picked from commit c05ae111d08688438af3733bade4596954180c65) --- InvenTree/InvenTree/static/script/inventree/inventree.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index 92239cec86..32e94c1a24 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -278,7 +278,7 @@ function loadBrandIcon(element, name) { /* * Function to sanitize a (potentially nested) object. * Iterates through all levels, and sanitizes each primitive string. - * + * * Note that this function effectively provides a "deep copy" of the provided data, * and the original data structure is unaltered. */ @@ -287,10 +287,12 @@ function sanitizeData(data) { return null; } else if (Array.isArray(data)) { // Handle arrays - var ret = []; + var arr = []; data.forEach(function(val) { - ret.push(sanitizeData(val)); + arr.push(sanitizeData(val)); }); + + return arr; } else if (typeof(data) === 'object') { // Handle nested structures var nested = {};