Unrestricted Upload of File with Dangerous Type in inventree/inventree
Description
Unrestricted Upload of File with Dangerous Type in GitHub repository inventree/inventree prior to 0.7.2.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
InvenTree prior to 0.7.2 allowed authenticated users to upload arbitrary files as attachments, enabling stored XSS when other users open them in the browser.
Vulnerability
CVE-2022-2111 is an unrestricted file upload vulnerability in InvenTree, an open-source inventory management system. The application allowed authenticated users to upload files with dangerous types (e.g., HTML files containing JavaScript) as attachments to database records. This lack of validation on file content and extension meant that any file could be uploaded without restriction [1][3].
Exploitation
An attacker with a valid user account could upload a malicious HTML file containing JavaScript code. When another user views the attachment in their browser (e.g., by clicking the link), the malicious script executes in the context of the InvenTree application. No additional privileges beyond a standard user account are required to perform the upload [3].
Impact
Successful exploitation leads to stored cross-site scripting (XSS). An attacker can execute arbitrary JavaScript in the victim's browser, potentially stealing session cookies, performing actions on behalf of the victim, or defacing the application. The impact is limited to users who open the malicious attachment in the browser [3].
Mitigation
The issue is patched in InvenTree version 0.7.2 (backported to the 0.7.x branch) and in the upcoming 0.8.0 release. Users are advised to upgrade immediately. As a workaround, users can right-click attachment links and select "Save link as" to download the file before opening it, which prevents automatic execution in the browser context [1][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
5- github.com/advisories/GHSA-fr2w-mp56-g4xpghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-2111ghsaADVISORY
- github.com/inventree/InvenTree/security/advisories/GHSA-fr2w-mp56-g4xpghsaWEB
- github.com/inventree/inventree/commit/26bf51c20a1c9b3130ac5dd2e17649bece5ff84fghsax_refsource_MISCWEB
- huntr.dev/bounties/a0e5c68e-0f75-499b-bd7b-d935fb8c0cd1ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.