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/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js
index 7999e4a7be..32e94c1a24 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 ca6297aa50..270063ba41 100644
--- a/InvenTree/InvenTree/version.py
+++ b/InvenTree/InvenTree/version.py
@@ -12,7 +12,7 @@ import common.models
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 5988850fe4..01dfb4e594 100644
--- a/InvenTree/build/admin.py
+++ b/InvenTree/build/admin.py
@@ -2,16 +2,15 @@ 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 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 d3bf75dab3..d3fb63358f 100644
--- a/InvenTree/company/admin.py
+++ b/InvenTree/company/admin.py
@@ -3,8 +3,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,
@@ -12,8 +12,8 @@ from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
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 a46fe62532..aa24c095f6 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 @@ from .models import (PurchaseOrder, PurchaseOrderExtraLine,
# 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 PurchaseOrderResource(ModelResource):
]
-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 SalesOrderResource(ModelResource):
]
-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 88064ff275..e82b29ba11 100644
--- a/InvenTree/part/admin.py
+++ b/InvenTree/part/admin.py
@@ -3,15 +3,15 @@ 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 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 85d7b7afe0..80db75811d 100644
--- a/InvenTree/stock/admin.py
+++ b/InvenTree/stock/admin.py
@@ -3,10 +3,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
@@ -14,8 +14,8 @@ from .models import (StockItem, StockItemAttachment, StockItemTestResult,
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 dd1fe31c66..53b1d90b6d 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 b4d5fe6cdc..8c6c79843d 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 f0b7b28a73..3a664b1f4d 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 a20978dd7d..c933acc743 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, {