mirror of
https://github.com/inventree/InvenTree.git
synced 2026-11-09 18:37:50 +00:00
Sanitize exported data (#13084)
This commit is contained in:
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)
|
||||
|
||||
|
||||
@@ -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()))
|
||||
Reference in new issue
Block a user