Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix CSV formula injection in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Spreadsheets run a cell that starts with =, +, -, @, tab or carriage return. Before writing a user value to CSV, prefix such a cell with a single quote so it stays text, then quote for RFC 4180 (commas, quotes and newlines). Quoting alone is not enough: the spreadsheet strips CSV quotes and still sees the formula.

Stack
  • Language JavaScript (ES modules)
  • Runtime Node.js 26.11.1
  • Framework Express 5.3.0
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks5 cards
Download the project ZIP, 23 KB
On this page

Steps

  1. Find every export that puts user-entered data into CSV, TSV or anything a spreadsheet opens: reports, contact lists, audit logs, support tickets.
  2. Neutralize each cell: if its first non-space character is =, +, -, @, a tab or a carriage return, prefix it with a single quote ('). Do this to the value, before any CSV quoting.
  3. Serialize for RFC 4180: wrap a field that contains a comma, a double quote or a newline in double quotes and double any inner quotes. This keeps the file well formed; it does not stop formula execution.
  4. Serve the file as text/csv with Content-Disposition: attachment so a browser downloads it instead of rendering it.
  5. Decide how to treat values that are legitimately a number with a leading sign or a leading minus: the single-quote prefix turns them into text. If that matters, prefix only when the value is not a valid number, and say so.
  6. Add regression tests that parse the generated CSV and assert no cell begins with a formula trigger, and that commas, quotes and newlines still round-trip.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.jsJavaScript
export function toCsv(columns, rows) {
  const lines = [columns.join(',')];
  for (const row of rows) {
    lines.push(columns.map((column) => row[column]).join(','));
  }
  return lines.join('\r\n') + '\r\n';
}

With IMPL=vulnerable, 5 of 6 tests fail (2 exploit tests).

The fix

Fixedimpl/fixed.jsJavaScript
// The leading characters a spreadsheet treats as the start of a formula.
const FORMULA_START = /^[=+\-@\t\r]/;

function neutralize(value) {
  const text = String(value);
  // Look past leading spaces, as a spreadsheet does, before deciding.
  return FORMULA_START.test(text.replace(/^ +/, '')) ? `'${text}` : text;
}

function quote(value) {
  return /[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
}

function cell(value) {
  return quote(neutralize(value));
}

export function toCsv(columns, rows) {
  const lines = [columns.map(cell).join(',')];
  for (const row of rows) {
    lines.push(columns.map((column) => cell(row[column])).join(','));
  }
  return lines.join('\r\n') + '\r\n';
}

With IMPL=fixed, 6 of 6 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.jsJavaScript
function quote(value) {
  const text = String(value);
  return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
}

export function toCsv(columns, rows) {
  const lines = [columns.map(quote).join(',')];
  for (const row of rows) {
    lines.push(columns.map((column) => quote(row[column])).join(','));
  }
  return lines.join('\r\n') + '\r\n';
}

With IMPL=wrong, 2 of 6 tests fail (2 exploit tests).

Route: a download a spreadsheet will open

app.jsJavaScript
// The contacts were typed by the public, so every field is attacker-controlled.
// impl.toCsv serializes them; the export is downloaded, not shown, but a
// spreadsheet opens it, so a cell must never be read as a formula.
app.get('/export', (_req, res) => {
  res
    .type('text/csv; charset=utf-8')
    .set('Content-Disposition', 'attachment; filename="contacts.csv"')
    .send(impl.toCsv(COLUMNS, contacts));
});

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/export.test.jsJavaScript
describe(`exploit (IMPL=${implName})`, () => {
  test('no exported cell is read as a formula by a spreadsheet', async () => {
    const { rows } = await exportCsv();
    const offenders = rows
      .slice(1)
      .flat()
      .filter((value) => FORMULA_START.test(value));
    assert.deepEqual(offenders, [], `these cells start with a formula trigger: ${JSON.stringify(offenders)}`);
  });

  test('the HYPERLINK payload is neutralized, not just quoted', async () => {
    const { rows } = await exportCsv();
    const cell = rows.flat().find((value) => value.includes('HYPERLINK'));
    assert.ok(cell, 'the payload is in the export');
    assert.ok(!cell.startsWith('='), `the payload is still a live formula: ${cell}`);
  });
});

Pitfalls

  • Quoting the field is not neutralizing the formula. "=HYPERLINK(...)" is valid RFC 4180 and still a live formula: the spreadsheet removes the CSV quotes and reads the leading =. That is this card's wrong implementation.
  • Forgetting the less obvious triggers: +, -, @, and a leading tab or carriage return all start a formula or let one in. = alone is not the whole list.
  • A leading space can hide a trigger from a naive check but not from the spreadsheet; strip leading spaces before deciding, or neutralize on a leading space too.
  • Prefixing every -5 or +44 7700 900000 with a quote changes legitimate data into text. If round-tripping numbers matters, prefix only non-numeric values; otherwise document that signed values become text.
  • The same payloads apply to TSV, DIF, SYLK and clipboard data, and to CSVs produced by a library whose defaults do not sanitize formulas. Sanitize the value, whatever writes the file.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csv-formula-injection/node-express does this for you).
  2. Export data that includes cells beginning with =, +, -, @, tab and carriage return, parse the CSV back, and assert no cell's value begins with one of those; also confirm commas, quotes and newlines survive the round trip.
  3. Opening the vulnerable and wrong exports in a spreadsheet runs the =HYPERLINK and =1+1 cells; the fixed export shows them as text. Do not rely on a scanner: no Semgrep or CodeQL rule covers CWE-1236, so the parsing tests are the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix CSV formula injection in Node.js and Express
ToolRuleWhat it found here
Semgrepp/javascript0 findings on app.js and impl/*.js. No registry rule models CSV formula injection: the vulnerable, fixed and wrong serializers look the same to these packs.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0

Neither the Semgrep registry nor github/codeql has a CWE-1236 query: static analysis does not know which strings a spreadsheet will open. The regression tests, which parse the CSV and check each cell, are the control.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1
FrameworkExpress 5.3.0
express5.3.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the csv-formula-injection/node-expressfolder.

Shell
npm ci
Shell
# Expected: every test passes
IMPL=fixed npm test --silent

# Expected: an exploit test fails
IMPL=vulnerable npm test --silent

# Expected: a test fails
IMPL=wrong npm test --silent

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs csv-formula-injection/node-express runs all three modes and checks the expected results.

Download the project ZIP, 23 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • node 26.11.1
  • npm 11.20.0
Matrix
fixed: 6 of 6 tests pass; vulnerable: 5 of 6 tests fail (2 exploit tests); wrong: 2 of 6 tests fail (2 exploit tests)
Test results
IMPL=fixed 6/6 pass; IMPL=vulnerable 5/6 fail (both exploit tests, plus the comma, quote and well-formed-structure functional tests because it does not quote at all); IMPL=wrong 2/6 fail (both exploit tests: it quotes correctly for RFC 4180 but never neutralizes a leading =, +, -, @, tab or carriage return).

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Top 10:2025 A05 Injection
  2. CWE-1236: Improper Neutralization of Formula Elements in a CSV File
  3. OWASP CSV Injection
  4. OWASP WSTG (latest): CSV Injection (WSTG-INJT-21)
  5. RFC 4180: Common Format and MIME Type for CSV Files

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.