Improper Neutralization of Formula Elements in a CSV File in inventree/inventree
Description
Improper Neutralization of Formula Elements in a CSV File in GitHub repository inventree/inventree prior to 0.7.2.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
CVE-2022-2112: CSV injection in InvenTree allows formula execution in exported CSV files, leading to code execution.
Vulnerability
Overview CVE-2022-2112 describes a CSV injection vulnerability in the InvenTree inventory management system prior to version 0.7.2. The issue arises from improper neutralization of formula elements when exporting data to CSV files, which means that maliciously crafted data can be interpreted by spreadsheet applications (such as Microsoft Excel or LibreOffice Calc) as executable formulas rather than plain text. The root cause is the lack of sanitization of cell values that start with characters like =, +, -, or @, which can be interpreted as spreadsheet formulas [1] [2].
Exploitation
Vector An attacker can exploit this vulnerability by creating a part, order, or other inventory item whose name or metadata includes a malformed formula string. When this data is later exported to a CSV file via InvenTree's admin interface import/export functionality, the formula will be embedded in the resulting CSV file. An unsuspecting user who opens this CSV file with a spreadsheet program that evaluates formulas upon opening will trigger execution of the attacker-controlled formula. The attack requires the ability to create or modify inventory items with crafted data, but does not require the exporter to have any special privileges beyond normal export access [1] [3].
Impact
Successful exploitation can lead to unauthorized disclosure of local files on the user's machine, execution of arbitrary commands, or further compromise through macro-based attacks. Since the formula runs in the context of the spreadsheet application, it can access local system resources. This is a standard CSV injection scenario, and its impact is especially severe if the exported CSV is opened by an administrator [2] [4].
Mitigation
The vulnerability was patched in commit 26bf51c20a1c9b3130ac5dd2e17649bece5ff84f, which was part of version 0.7.2 and later releases. The fix involves using a safer resource base class (InvenTreeResource) that properly escapes formula-starting characters in exported CSV data. Users are strongly advised to upgrade to version 0.7.2 or later and to educate users about the risks of opening CSV files from untrusted sources [1] [2] [3].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
inventreePyPI | < 0.7.2 | 0.7.2 |
Affected products
1- Range: unspecified
Patches
126bf51c20a1cBack porting of security patches (#3197)
12 files changed · +195 −63
InvenTree/build/admin.py+3 −4 modified@@ -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!
InvenTree/company/admin.py+11 −17 modified@@ -3,17 +3,17 @@ 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, ManufacturerPartParameter, SupplierPart, 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))
InvenTree/InvenTree/admin.py+33 −0 added@@ -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
InvenTree/InvenTree/static/script/inventree/inventree.js+37 −0 modified@@ -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, ''').replace(/`/g, '`'); + } else { + return data; + } +} + + // Convenience function to determine if an element exists $.fn.exists = function() { return this.length !== 0;
InvenTree/InvenTree/version.py+1 −1 modified@@ -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():
InvenTree/order/admin.py+37 −24 modified@@ -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,18 +33,21 @@ class GeneralExtraLineAdmin: class GeneralExtraLineMeta: + """Metaclass template for the 'ExtraLineItem' models""" skip_unchanged = True report_skipped = False clean_model_instances = True # endregion 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',
InvenTree/part/admin.py+9 −9 modified@@ -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))
InvenTree/stock/admin.py+5 −5 modified@@ -3,19 +3,19 @@ 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 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))
InvenTree/templates/js/translated/attachment.js+1 −1 modified@@ -149,7 +149,7 @@ function loadAttachmentTable(url, options) { var html = `<span class='fas ${icon}'></span> ${filename}`; - return renderLink(html, value); + return renderLink(html, value, {download: true}); } else if (row.link) { var html = `<span class='fas fa-link'></span> ${row.link}`; return renderLink(html, row.link);
InvenTree/templates/js/translated/forms.js+3 −0 modified@@ -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) {
InvenTree/templates/js/translated/stock.js+2 −1 modified@@ -1306,7 +1306,8 @@ function loadStockTestResultsTable(table, options) { var html = value; if (row.attachment) { - html += `<a href='${row.attachment}'><span class='fas fa-file-alt float-right'></span></a>`; + var text = `<span class='fas fa-file-alt float-right'></span>`; + html += renderLink(text, row.attachment, {download: true}); } return html;
InvenTree/templates/js/translated/tables.js+53 −1 modified@@ -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 '<a href="' + url + '">' + text + '</a>'; + return `<a href='${url}'${extra}>${text}</a>`; } @@ -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, {
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-9hx5-jmxv-x44qghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-2112ghsaADVISORY
- github.com/inventree/inventree/commit/26bf51c20a1c9b3130ac5dd2e17649bece5ff84fghsax_refsource_MISCWEB
- huntr.dev/bounties/e57c36e7-fa39-435f-944a-3a52ee066f73ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.