Sanitize exported data (#13084)

This commit is contained in:
Oliver authored and GitHub committed 2026-10-24 15:51:55 +11:00
1 parent e701eaac22
commit 66e3278ea7
4 files changed
+95 -4

No files matched your search

@@ -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:
@@ -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)
+4 -2
View File
@@ -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')
@@ -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()))