Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions src/exporters/CSVExporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
Comment on lines 42 to 49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Numeric-string exemption keeps leading +/- values unescaped by design

The new numeric guard (sanitizeCsvFormulaInjection at src/exporters/CSVExporter.js:43-46) returns cells like -273.15, +1, -1 unchanged when Number.isFinite(Number(trimmed)) is true, even though they begin with a formula-trigger character (+/-). This is an intentional tradeoff to keep numeric telemetry readable as numbers in spreadsheets, and it is safe because a purely finite-numeric string cannot form a malicious formula (values like -1+1, =cmd, @x yield NaN and remain escaped). Worth noting for reviewers that this deviates from the strict OWASP rule of escaping every cell starting with =+-@, but poses no practical injection risk.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


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 {
/**
Expand All @@ -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;
});
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
let csvText = new CSV(sanitizedRows, { header: headers }).encode();
Comment on lines +72 to +80

@devin-ai-integration devin-ai-integration Bot Jul 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Sanitized rows only carry header keys; equivalent to prior header-filtered output

export() now rebuilds each row containing only the headers keys (src/exporters/CSVExporter.js:72-79) rather than passing the full original row objects. Since new CSV(rows, { header: headers }) already filtered output to the header set, and missing keys resolve to undefined either way, this is behavior-preserving. Also, all telemetry/unit/name cells arrive as strings from getFormattedValue (TelemetryTableColumn.js:52-55 coerces non-strings via toString()), so changing the fall-through from return str to return value does not alter output for the actual caller.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

let blob = new Blob([csvText], { type: 'text/csv' });
saveAs(blob, filename);
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Expand Down
19 changes: 2 additions & 17 deletions src/plugins/telemetryTable/components/TableComponent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
});
Expand Down
Loading