You build a waitlist. You add an admin button that exports signups as CSV. You open it in Excel to check the numbers. What could go wrong? Everything.
A user who signs up with the name =CMD|'/C calc'!A0 just planted a formula in your export. When you open that CSV in a spreadsheet, it doesn't see text — it sees an instruction. And it runs it.
What Is CSV Formula Injection?
Spreadsheet applications like Excel, Google Sheets, and LibreOffice Calc treat certain characters at the start of a cell as formula triggers. The dangerous characters are:
=— the classic formula prefix+— interpreted as a formula (e.g.+1+2evaluates to 3)-— same as+, treated as arithmetic@— triggers Excel's implicit intersection operator\t(tab) and\r(carriage return) — can break cell boundaries, enabling injection into adjacent cells
If any user-controlled data starts with one of these characters and ends up in a CSV cell, the spreadsheet will try to evaluate it. The attacker doesn't need access to your admin panel — they just need to be a row in your database.
How We Found It
Our grep-based audit on server.js found the escapeCsv() function at line 2702. It handled the obvious CSV problems: wrapping values in double quotes and escaping internal quotes. This is correct for making CSV parseable. But it did nothing about formula characters.
// Before — handles CSV structure, not formula injection
function escapeCsv(val) {
if (typeof val !== 'string') return val;
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return '"' + val.replace(/"/g, '""') + '"';
}
return val;
}
The function was doing its structural job perfectly. Commas were handled. Quotes were escaped. Newlines were wrapped. But a value starting with = passed through untouched.
The Attack Path
The attack is simple and doesn't require any technical sophistication from the attacker:
- Step 1: Attacker signs up with name
=CMD|'/C calc'!A0or email containing a formula prefix - Step 2: Their data is stored in the database —
sanitise()strips HTML-dangerous chars but preserves=and+because they're legitimate in names and emails - Step 3: Admin clicks "Export CSV" (behind dashboardAuth + adminOnly middleware)
- Step 4: Admin opens the CSV in Excel
- Step 5: Excel evaluates the formula. On Windows,
CMDcan execute arbitrary commands. On any platform,HYPERLINKcan exfiltrate data
The irony: the export endpoint is properly protected with authentication and admin-only access. But the attacker doesn't need to call the endpoint — they need the admin to open the file. The admin's own trust in their export does the rest.
The Fix: One Character
The solution is to prefix any cell value that starts with a formula character with a single quote '. In spreadsheet applications, a leading single quote means "treat this cell as text, not as a formula." It's a universal convention across Excel, Google Sheets, and LibreOffice.
// After — formula injection neutralised
function escapeCsv(val) {
if (typeof val !== 'string') return val;
// Neutralise formula injection
if (/^[=+\-@\t\r]/.test(val)) {
val = "'" + val;
}
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return '"' + val.replace(/"/g, '""') + '"';
}
return val;
}
The regex /^[=+\-@\t\r]/ checks if the value starts with any formula-triggering character. If it does, a single quote is prepended. The spreadsheet sees '=CMD|'/C calc'!A0 and renders it as the literal text =CMD|'/C calc'!A0. No evaluation. No execution.
Why sanitise() Wasn't Enough
We already had sanitise() running on all user inputs at the storage boundary. It strips angle brackets, script tags, and other HTML-dangerous characters. But =, +, and - are not dangerous in HTML — they're normal characters in names, emails, and messages.
This is the same lesson we learned with escapeHtml(): every output context has its own escaping rules.
- HTML output needs
escapeHtml()— encodes<,>,&,",' - SQL output needs parameterised queries — prevents injection via
'and; - CSV output needs formula neutralisation — prevents injection via
=,+,-,@
sanitise() protects the database. escapeHtml() protects the browser. escapeCsv() protects the spreadsheet. One function cannot serve all three contexts.
Defence in Depth — and Its Limits
Our CSV export was already behind two layers of protection: dashboardAuth (JWT verification) and adminOnly (role check). Only authenticated admins can trigger the export. That's good defence in depth.
But authentication protects the endpoint, not the output. The attacker's payload doesn't need to reach the endpoint — it reaches the admin's spreadsheet application, which has no concept of authentication or input validation. The trust chain breaks at the point where server-controlled data enters a client-side application that interprets content.
This is the same pattern as stored XSS: the attacker stores a payload during signup (an unauthenticated action), and it detonates when a privileged user views it in a different context. In XSS, the context is a browser. In CSV injection, it's a spreadsheet. The structure is identical.
The Broader Pattern
Across our security audit series, we keep finding the same structural lesson: the danger is not in what you store, but in where you render it.
- Stored XSS: safe in the database, dangerous in HTML
- CSV injection: safe in the database, dangerous in spreadsheets
- The fix is always the same shape: escape at the output boundary, specific to the output format
If you have a CSV export anywhere in your application, grep for your export function and check whether it handles formula characters. If it only handles commas and quotes, you have the same bug we did. The fix is one regex and one character.