From 66e3278ea7ea687f62693fd3c8f8d9207d238549 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 24 Oct 2026 15:51:55 +1100 Subject: [PATCH] Sanitize exported data (#13084) --- src/backend/InvenTree/InvenTree/helpers.py | 15 ++++ src/backend/InvenTree/data_exporter/mixins.py | 6 +- src/backend/InvenTree/part/stocktake.py | 6 +- .../plugin/builtin/exporter/test_exporter.py | 72 +++++++++++++++++++ 4 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index 3c6c075daa..10d42f076e 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -510,6 +510,21 @@ def GetExportFormats() -> list: return [opt[0] for opt in GetExportOptions()] +def escape_export_value(value): + """Escape a value for export to a tabular data file, to prevent formula injection. + + String values which start with a character that a spreadsheet application + may interpret as a formula are prefixed with a single quote character. + Non-string values are returned unchanged. + + Ref: https://owasp.org/www-community/attacks/CSV_Injection + """ + if isinstance(value, str) and value.startswith(('=', '+', '-', '@', '\t', '\r')): + return "'" + value + + return value + + def DownloadFile( data, filename, content_type='application/text', inline=False ) -> StreamingHttpResponse: diff --git a/src/backend/InvenTree/data_exporter/mixins.py b/src/backend/InvenTree/data_exporter/mixins.py index 05876bfa75..14886876c9 100644 --- a/src/backend/InvenTree/data_exporter/mixins.py +++ b/src/backend/InvenTree/data_exporter/mixins.py @@ -18,7 +18,7 @@ import data_exporter.tasks import InvenTree.exceptions import InvenTree.serializers from common.models import DataOutput -from InvenTree.helpers import str2bool +from InvenTree.helpers import escape_export_value, str2bool from InvenTree.tasks import offload_task from plugin import PluginMixinEnum, registry @@ -218,7 +218,9 @@ class DataExportSerializerMixin: dataset = tablib.Dataset(headers=field_headers) for row in data: - dataset.append([self.get_nested_value(row, f) for f in field_names]) + dataset.append([ + escape_export_value(self.get_nested_value(row, f)) for f in field_names + ]) return dataset.export(file_format) diff --git a/src/backend/InvenTree/part/stocktake.py b/src/backend/InvenTree/part/stocktake.py index a7b971680a..774dcac905 100644 --- a/src/backend/InvenTree/part/stocktake.py +++ b/src/backend/InvenTree/part/stocktake.py @@ -10,7 +10,7 @@ from djmoney.contrib.exchange.models import convert_money from djmoney.money import Money import common.models -from InvenTree.helpers import current_date +from InvenTree.helpers import current_date, escape_export_value logger = structlog.get_logger('inventree') @@ -227,7 +227,9 @@ def perform_stocktake( for entry in history_entries: entry.date = today row = serializer.to_representation(entry) - dataset.append([row.get(header, '') for header in header_keys]) + dataset.append([ + escape_export_value(row.get(header, '')) for header in header_keys + ]) datafile = dataset.export('csv') diff --git a/src/backend/InvenTree/plugin/builtin/exporter/test_exporter.py b/src/backend/InvenTree/plugin/builtin/exporter/test_exporter.py index cf1e9c9af8..11fab94569 100644 --- a/src/backend/InvenTree/plugin/builtin/exporter/test_exporter.py +++ b/src/backend/InvenTree/plugin/builtin/exporter/test_exporter.py @@ -80,3 +80,75 @@ class StocktakeExporterTest(InvenTreeAPITestCase): # Reset plugin state registry.set_plugin_state(slug, False) + + +class FormulaInjectionExportTest(InvenTreeAPITestCase): + """Test that exported data is escaped to prevent formula injection. + + Ref: https://owasp.org/www-community/attacks/CSV_Injection + """ + + roles = ['part.view'] + + NAMES = { + '=2+2': "'=2+2", + '+1+1': "'+1+1", + '-12V Regulator': "'-12V Regulator", + '@SUM(1)': "'@SUM(1)", + 'Safe Part': 'Safe Part', + } + + @classmethod + def setUpTestData(cls): + """Create parts with names which could be interpreted as formulae.""" + from part.models import Part, PartCategory + + super().setUpTestData() + + cls.category = PartCategory.objects.create(name='Formula Category') + + for name in cls.NAMES: + Part.objects.create( + name=name, description='Formula test', category=cls.category + ) + + def test_csv_export(self): + """Malicious string values are prefixed when exported to CSV.""" + url = reverse('api-part-list') + + with self.export_data( + url, params={'category': self.category.pk}, export_format='csv' + ) as data_file: + data = self.process_csv( + data_file, required_cols=['Name'], required_rows=len(self.NAMES) + ) + + names = {row['Name'] for row in data} + self.assertEqual(names, set(self.NAMES.values())) + + def test_xlsx_export(self): + """Malicious string values are not written as formula cells in XLSX.""" + import openpyxl + + url = reverse('api-part-list') + + with self.export_data( + url, + params={'category': self.category.pk}, + export_format='xlsx', + decode=False, + ) as data_file: + workbook = openpyxl.load_workbook(data_file) + + sheet = workbook.active + headers = [cell.value for cell in sheet[1]] + col = headers.index('Name') + 1 + + names = set() + + for row in sheet.iter_rows(min_row=2, min_col=col, max_col=col): + cell = row[0] + self.assertNotEqual(cell.data_type, 'f') + names.add(cell.value) + + self.assertEqual(names, set(self.NAMES.values()))