VYPR
High severityNVD Advisory· Published Jun 17, 2022· Updated Aug 3, 2024

Improper Neutralization of Formula Elements in a CSV File in inventree/inventree

CVE-2022-2112

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.

PackageAffected versionsPatched versions
inventreePyPI
< 0.7.20.7.2

Affected products

1

Patches

1
26bf51c20a1c

Back porting of security patches (#3197)

https://github.com/inventree/inventreeOliverJun 15, 2022via ghsa
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, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;').replace(/`/g, '&#x60;');
    +    } 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

News mentions

0

No linked articles in our index yet.