diff --git a/InvenTree/InvenTree/admin.py b/InvenTree/InvenTree/admin.py new file mode 100644 index 00000000000..2d5798a9d1e --- /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/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index 7999e4a7be0..32e94c1a243 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,42 @@ 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 arr = []; + data.forEach(function(val) { + arr.push(sanitizeData(val)); + }); + + return arr; + } 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/InvenTree/version.py b/InvenTree/InvenTree/version.py index ca6297aa50a..270063ba41c 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,7 +12,7 @@ from InvenTree.api_version import INVENTREE_API_VERSION # InvenTree software version -INVENTREE_SW_VERSION = "0.7.1" +INVENTREE_SW_VERSION = "0.7.2" def inventreeInstanceName(): diff --git a/InvenTree/build/admin.py b/InvenTree/build/admin.py index 5988850fe42..01dfb4e5945 100644 --- a/InvenTree/build/admin.py +++ b/InvenTree/build/admin.py @@ -2,16 +2,15 @@ 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 for managing import/export of Build data""" +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. # TODO: 2022-05-12 - Need to investigate why this is the case! diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py index d3bf75dab37..d3fb63358fd 100644 --- a/InvenTree/company/admin.py +++ b/InvenTree/company/admin.py @@ -3,8 +3,8 @@ 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, @@ -12,8 +12,8 @@ SupplierPriceBreak) -class CompanyResource(ModelResource): - """ Class for managing Company data import/export """ +class CompanyResource(InvenTreeResource): + """Class for managing Company data import/export.""" class Meta: model = Company @@ -34,10 +34,8 @@ class CompanyAdmin(ImportExportModelAdmin): ] -class SupplierPartResource(ModelResource): - """ - Class for managing SupplierPart data import/export - """ +class SupplierPartResource(InvenTreeResource): + """Class for managing SupplierPart data import/export.""" part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) @@ -70,10 +68,8 @@ class SupplierPartAdmin(ImportExportModelAdmin): autocomplete_fields = ('part', 'supplier', 'manufacturer_part',) -class ManufacturerPartResource(ModelResource): - """ - Class for managing ManufacturerPart data import/export - """ +class ManufacturerPartResource(InvenTreeResource): + """Class for managing ManufacturerPart data import/export.""" part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) @@ -118,10 +114,8 @@ class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin): autocomplete_fields = ('manufacturer_part',) -class ManufacturerPartParameterResource(ModelResource): - """ - Class for managing ManufacturerPartParameter data import/export - """ +class ManufacturerPartParameterResource(InvenTreeResource): + """Class for managing ManufacturerPartParameter data import/export.""" class Meta: model = ManufacturerPartParameter @@ -148,8 +142,8 @@ class ManufacturerPartParameterAdmin(ImportExportModelAdmin): autocomplete_fields = ('manufacturer_part',) -class SupplierPriceBreakResource(ModelResource): - """ Class for managing SupplierPriceBreak data import/export """ +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 a46fe62532c..aa24c095f6d 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -1,9 +1,12 @@ +"""Admin functionality for the 'order' app""" + 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, @@ -13,6 +16,7 @@ # region general classes class GeneralExtraLineAdmin: + """Admin class template for the 'ExtraLineItem' models""" list_display = ( 'order', 'quantity', @@ -29,6 +33,7 @@ class GeneralExtraLineAdmin: class GeneralExtraLineMeta: + """Metaclass template for the 'ExtraLineItem' models""" skip_unchanged = True report_skipped = False clean_model_instances = True @@ -36,11 +41,13 @@ class GeneralExtraLineMeta: class PurchaseOrderLineItemInlineAdmin(admin.StackedInline): + """Inline admin class for the PurchaseOrderLineItem model""" model = PurchaseOrderLineItem extra = 0 class PurchaseOrderAdmin(ImportExportModelAdmin): + """Admin class for the PurchaseOrder model""" exclude = [ 'reference_int', @@ -68,6 +75,7 @@ class PurchaseOrderAdmin(ImportExportModelAdmin): class SalesOrderAdmin(ImportExportModelAdmin): + """Admin class for the SalesOrder model""" exclude = [ 'reference_int', @@ -90,10 +98,8 @@ class SalesOrderAdmin(ImportExportModelAdmin): autocomplete_fields = ('customer',) -class PurchaseOrderResource(ModelResource): - """ - Class for managing import / export of PurchaseOrder data - """ +class PurchaseOrderResource(InvenTreeResource): + """Class for managing import / export of PurchaseOrder data.""" # Add number of line items line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True) @@ -102,6 +108,7 @@ class PurchaseOrderResource(ModelResource): overdue = Field(attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True) class Meta: + """Metaclass""" model = PurchaseOrder skip_unchanged = True clean_model_instances = True @@ -110,8 +117,8 @@ class Meta: ] -class PurchaseOrderLineItemResource(ModelResource): - """ Class for managing import / export of PurchaseOrderLineItem data """ +class PurchaseOrderLineItemResource(InvenTreeResource): + """Class for managing import / export of PurchaseOrderLineItem data.""" part_name = Field(attribute='part__part__name', readonly=True) @@ -122,23 +129,24 @@ class PurchaseOrderLineItemResource(ModelResource): SKU = Field(attribute='part__SKU', readonly=True) class Meta: + """Metaclass""" model = PurchaseOrderLineItem skip_unchanged = True report_skipped = False clean_model_instances = True -class PurchaseOrderExtraLineResource(ModelResource): - """ Class for managing import / export of PurchaseOrderExtraLine data """ +class PurchaseOrderExtraLineResource(InvenTreeResource): + """Class for managing import / export of PurchaseOrderExtraLine data.""" class Meta(GeneralExtraLineMeta): + """Metaclass options.""" + model = PurchaseOrderExtraLine -class SalesOrderResource(ModelResource): - """ - Class for managing import / export of SalesOrder data - """ +class SalesOrderResource(InvenTreeResource): + """Class for managing import / export of SalesOrder data.""" # Add number of line items line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True) @@ -147,6 +155,7 @@ class SalesOrderResource(ModelResource): overdue = Field(attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True) class Meta: + """Metaclass options""" model = SalesOrder skip_unchanged = True clean_model_instances = True @@ -155,10 +164,8 @@ class Meta: ] -class SalesOrderLineItemResource(ModelResource): - """ - Class for managing import / export of SalesOrderLineItem data - """ +class SalesOrderLineItemResource(InvenTreeResource): + """Class for managing import / export of SalesOrderLineItem data.""" part_name = Field(attribute='part__name', readonly=True) @@ -169,31 +176,34 @@ class SalesOrderLineItemResource(ModelResource): fulfilled = Field(attribute='fulfilled_quantity', readonly=True) def dehydrate_sale_price(self, item): - """ - Return a string value of the 'sale_price' field, rather than the 'Money' object. + """Return a string value of the 'sale_price' field, rather than the 'Money' object. + Ref: https://github.com/inventree/InvenTree/issues/2207 """ - if item.sale_price: return str(item.sale_price) else: return '' class Meta: + """Metaclass options""" model = SalesOrderLineItem skip_unchanged = True report_skipped = False clean_model_instances = True -class SalesOrderExtraLineResource(ModelResource): - """ Class for managing import / export of SalesOrderExtraLine data """ +class SalesOrderExtraLineResource(InvenTreeResource): + """Class for managing import / export of SalesOrderExtraLine data.""" class Meta(GeneralExtraLineMeta): + """Metaclass options.""" + model = SalesOrderExtraLine class PurchaseOrderLineItemAdmin(ImportExportModelAdmin): + """Admin class for the PurchaseOrderLine model""" resource_class = PurchaseOrderLineItemResource @@ -210,11 +220,12 @@ class PurchaseOrderLineItemAdmin(ImportExportModelAdmin): class PurchaseOrderExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin): - + """Admin class for the PurchaseOrderExtraLine model""" resource_class = PurchaseOrderExtraLineResource class SalesOrderLineItemAdmin(ImportExportModelAdmin): + """Admin class for the SalesOrderLine model""" resource_class = SalesOrderLineItemResource @@ -236,11 +247,12 @@ class SalesOrderLineItemAdmin(ImportExportModelAdmin): class SalesOrderExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin): - + """Admin class for the SalesOrderExtraLine model""" resource_class = SalesOrderExtraLineResource class SalesOrderShipmentAdmin(ImportExportModelAdmin): + """Admin class for the SalesOrderShipment model""" list_display = [ 'order', @@ -258,6 +270,7 @@ class SalesOrderShipmentAdmin(ImportExportModelAdmin): class SalesOrderAllocationAdmin(ImportExportModelAdmin): + """Admin class for the SalesOrderAllocation model""" list_display = ( 'line', diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index 88064ff275f..e82b29ba118 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -3,15 +3,15 @@ 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 for managing Part data import/export """ +class PartResource(InvenTreeResource): + """Class for managing Part data import/export.""" # ForeignKey fields category = Field(attribute='category', widget=widgets.ForeignKeyWidget(models.PartCategory)) @@ -81,8 +81,8 @@ class PartAdmin(ImportExportModelAdmin): ] -class PartCategoryResource(ModelResource): - """ Class for managing PartCategory data import/export """ +class PartCategoryResource(InvenTreeResource): + """Class for managing PartCategory data import/export.""" parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory)) @@ -157,8 +157,8 @@ class PartTestTemplateAdmin(admin.ModelAdmin): autocomplete_fields = ('part',) -class BomItemResource(ModelResource): - """ Class for managing BomItem data import/export """ +class BomItemResource(InvenTreeResource): + """Class for managing BomItem data import/export.""" level = Field(attribute='level', readonly=True) @@ -269,8 +269,8 @@ class ParameterTemplateAdmin(ImportExportModelAdmin): search_fields = ('name', 'units') -class ParameterResource(ModelResource): - """ Class for managing PartParameter data import/export """ +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 85d7b7afe0c..80db75811d2 100644 --- a/InvenTree/stock/admin.py +++ b/InvenTree/stock/admin.py @@ -3,10 +3,10 @@ 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 @@ -14,8 +14,8 @@ StockItemTracking, StockLocation) -class LocationResource(ModelResource): - """ Class for managing StockLocation data import/export """ +class LocationResource(InvenTreeResource): + """Class for managing StockLocation data import/export.""" parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(StockLocation)) @@ -65,8 +65,8 @@ class LocationAdmin(ImportExportModelAdmin): ] -class StockItemResource(ModelResource): - """ Class for managing StockItem data import/export """ +class StockItemResource(InvenTreeResource): + """Class for managing StockItem data import/export.""" # Custom managers for ForeignKey fields part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part)) diff --git a/InvenTree/templates/js/translated/attachment.js b/InvenTree/templates/js/translated/attachment.js index dd1fe31c66e..53b1d90b6d0 100644 --- a/InvenTree/templates/js/translated/attachment.js +++ b/InvenTree/templates/js/translated/attachment.js @@ -149,7 +149,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/forms.js b/InvenTree/templates/js/translated/forms.js index b4d5fe6cdc0..8c6c79843d7 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/stock.js b/InvenTree/templates/js/translated/stock.js index f0b7b28a734..3a664b1f4d5 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1306,7 +1306,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 a20978dd7d1..c933acc7431 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -92,6 +92,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; @@ -102,7 +109,7 @@ function renderLink(text, url, options={}) { text = `${text_start}...${text_end}`; } - return '' + text + ''; + return `${text}`; } @@ -282,6 +289,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; @@ -468,6 +477,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, {