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.
- Find every export that puts user-entered data into CSV, TSV or anything a spreadsheet opens: reports, contact lists, audit logs, support tickets.
- 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.
- 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.
- Serve the file as text/csv with Content-Disposition: attachment so a browser downloads it instead of rendering it.
- 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.
- 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.
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
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
// 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
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
// 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 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.
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}`);
});
});- 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.
- 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).
- 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.
- 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.
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.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/javascript | 0 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.
| Component | Version |
|---|---|
| Language | JavaScript (ES modules) |
| Runtime | Node.js 26.11.1 |
| Framework | Express 5.3.0 |
| express | 5.3.0 |
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.
npm ci# 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 --silentEach 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.
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.