From 6c65d96ecec6951eaec8ac51a98dc231e4026d26 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 27 Oct 2026 11:55:24 +1100 Subject: [PATCH] Export escape fix (#13110) * Allow numeric values to be exported unescapted * Updated docs * Explicit regression tests --- docs/docs/concepts/data_export.md | 17 +++++++++ src/backend/InvenTree/InvenTree/helpers.py | 13 ++++++- .../plugin/builtin/exporter/test_exporter.py | 37 +++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/docs/docs/concepts/data_export.md b/docs/docs/concepts/data_export.md index bb5a20f369..a5bc825bdf 100644 --- a/docs/docs/concepts/data_export.md +++ b/docs/docs/concepts/data_export.md @@ -34,6 +34,23 @@ The following file formats are available for export: | Excel | Microsoft Excel format (`.xlsx`). Suitable for direct use in spreadsheet applications. | | TSV | Tab-separated values. Similar to CSV but uses tab characters as delimiters. | +## Value Escaping + +To prevent [CSV (formula) injection](https://owasp.org/www-community/attacks/CSV_Injection), exported text values which could be interpreted as a formula by a spreadsheet application are escaped by prefixing them with a single quote (`'`). A text value is escaped if it begins with any of the following characters: + +| Character | Description | +|-----------|-------------| +| `=` | Equals sign | +| `+` | Plus sign | +| `-` | Minus sign | +| `@` | At sign | +| Tab | Tab character | +| Carriage return | `\r` character | + +Strictly numeric values are exempt, and are exported as-is even if they begin with `+` or `-`. For example, `123`, `-222`, `+1.5` and `12e6` are not modified, while `-1+2` is exported as `'-1+2`. + +Non-text values (such as numbers or dates) are never modified. + ## Export Plugins InvenTree uses a plugin-based export system. The export dialog lists all plugins that are available for the data type being exported. Selecting a different plugin may provide additional export options or a different output format. diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index 10d42f076e..8f2fa7d447 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -515,11 +515,20 @@ def escape_export_value(value): 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. + Non-string values are returned unchanged, as are strictly numeric + strings (e.g. "-222", "+1.5", "12e6"), which are not a formula risk. Ref: https://owasp.org/www-community/attacks/CSV_Injection """ - if isinstance(value, str) and value.startswith(('=', '+', '-', '@', '\t', '\r')): + if not isinstance(value, str): + return value + + if value.startswith(('+', '-')) and re.fullmatch( + r'[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?', value + ): + return value + + if value.startswith(('=', '+', '-', '@', '\t', '\r')): return "'" + value return value diff --git a/src/backend/InvenTree/plugin/builtin/exporter/test_exporter.py b/src/backend/InvenTree/plugin/builtin/exporter/test_exporter.py index 11fab94569..654c575ef6 100644 --- a/src/backend/InvenTree/plugin/builtin/exporter/test_exporter.py +++ b/src/backend/InvenTree/plugin/builtin/exporter/test_exporter.py @@ -1,7 +1,9 @@ """Unit test for the exporter plugins.""" +from django.test import TestCase from django.urls import reverse +from InvenTree.helpers import escape_export_value from InvenTree.unit_test import InvenTreeAPITestCase from plugin.registry import registry @@ -152,3 +154,38 @@ class FormulaInjectionExportTest(InvenTreeAPITestCase): names.add(cell.value) self.assertEqual(names, set(self.NAMES.values())) + + +class EscapeExportValueTest(TestCase): + """Unit tests for the escape_export_value helper.""" + + def test_numeric_unchanged(self): + """Strictly numeric values are not escaped, even with a +/- prefix.""" + for value in ['123', '-222', '+5', '123.4', '-123.4', '+.5', '12e6', '-1.5E-3']: + self.assertEqual(escape_export_value(value), value) + + def test_non_numeric_escaped(self): + """Non-numeric values with a dangerous prefix are escaped.""" + for value in [ + '=2+2', + '+1+1', + '-1-1', + '-12V Regulator', + '-', + '+', + '-1e', + '-nan', + '-inf', + '-1 ', + '- 1', + '-1,2', + '@SUM(1)', + '\tabc', + '\rabc', + ]: + self.assertEqual(escape_export_value(value), "'" + value) + + def test_other_values(self): + """Safe strings and non-string values are returned unchanged.""" + for value in ['Safe Part', '', 'a-b', 42, -42, 1.5, None, True]: + self.assertEqual(escape_export_value(value), value)