diff --git a/src/exporters/CSVExporter.js b/src/exporters/CSVExporter.js index 3dffe00c5f..2ec95829de 100644 --- a/src/exporters/CSVExporter.js +++ b/src/exporters/CSVExporter.js @@ -27,30 +27,37 @@ import { saveAs } from 'file-saver'; * Neutralize spreadsheet formula injection (CSV injection) for a cell value by * prefixing with a single quote when the value could be interpreted as a * formula (leading =, +, -, @, tab, CR, optionally after whitespace). + * Numeric values (e.g. negative telemetry readings like -273.15) are returned + * unchanged so spreadsheets still treat them as numbers. * @see https://owasp.org/www-community/attacks/CSV_Injection * @param {*} value * @returns {*} */ export function sanitizeCsvFormulaInjection(value) { - if (value === null || value === undefined) { + if (value === null || value === undefined || typeof value === 'number') { return value; } const str = String(value); if (/^\s*[=+\-@\t\r]/.test(str)) { + const trimmed = str.trim(); + if (trimmed !== '' && Number.isFinite(Number(trimmed))) { + return value; + } + return `'${str}`; } - return str; + return value; } /** * Encodes tabular data as CSV and triggers a browser download via FileSaver. * - * This layer does not sanitize cell values or filenames. Any user-controlled text - * (including Open MCT object `name` fields shown in exported rows) should be passed - * through {@link sanitizeCsvFormulaInjection} where spreadsheet tools could treat - * leading `=`, `+`, etc. as formulas. + * Every exported cell is passed through {@link sanitizeCsvFormulaInjection} so + * user-controlled text (object names, string telemetry values, unit metadata) + * cannot be interpreted as a spreadsheet formula (leading `=`, `+`, `-`, `@`, + * tab, or CR). */ class CSVExporter { /** @@ -62,7 +69,15 @@ class CSVExporter { export(rows, options) { let headers = (options && options.headers) || Object.keys(rows[0] || {}).sort(); let filename = (options && options.filename) || 'export.csv'; - let csvText = new CSV(rows, { header: headers }).encode(); + let sanitizedRows = rows.map((row) => { + let sanitizedRow = {}; + headers.forEach((header) => { + sanitizedRow[header] = sanitizeCsvFormulaInjection(row[header]); + }); + + return sanitizedRow; + }); + let csvText = new CSV(sanitizedRows, { header: headers }).encode(); let blob = new Blob([csvText], { type: 'text/csv' }); saveAs(blob, filename); } diff --git a/src/plugins/telemetryTable/components/TableComponent.vue b/src/plugins/telemetryTable/components/TableComponent.vue index e4173d9043..7f3ae73e1a 100644 --- a/src/plugins/telemetryTable/components/TableComponent.vue +++ b/src/plugins/telemetryTable/components/TableComponent.vue @@ -291,7 +291,7 @@ import { onMounted, ref, toRaw } from 'vue'; import stalenessMixin from '@/ui/mixins/staleness-mixin'; -import CSVExporter, { sanitizeCsvFormulaInjection } from '../../../exporters/CSVExporter.js'; +import CSVExporter from '../../../exporters/CSVExporter.js'; import ProgressBar from '../../../ui/components/ProgressBar.vue'; import Search from '../../../ui/components/SearchComponent.vue'; import ToggleSwitch from '../../../ui/components/ToggleSwitch.vue'; @@ -833,25 +833,10 @@ export default { // which causes subsequent scroll to use an out of date height. this.contentTable.style.height = this.totalHeight + 'px'; }, - /** - * Object display names are user input; sanitize the name column for CSV injection. - * If other call sites pass object names into CSV, use the same helper on those fields. - */ exportAsCSV(data) { const headerKeys = Object.keys(this.headers); - const nameKey = 'name'; - const sanitizedData = data.map((row) => { - if (!row[nameKey]) { - return row; - } - - return { - ...row, - [nameKey]: sanitizeCsvFormulaInjection(row[nameKey]) - }; - }); - this.csvExporter.export(sanitizedData, { + this.csvExporter.export(data, { filename: this.table.domainObject.name + '.csv', headers: headerKeys });