TLDR: If you export user-supplied data to CSV, you need to sanitize
=,+,-,@, TAB, and CR before writing. One regex, 10 lines. The victim isn't your web app — it's whoever opens the file.
the setup
We run an ecommerce business's affiliate payout system. Every month: ingest AEvent (our webinar platform) registrant CSVs, deduplicate leads, spit out a summary sheet and a detail sheet. Finance downloads those files and opens them in Excel to process payouts.
I was in the middle of a fleet-wide security sweep across all the ecommerce business apps — six projects, a couple hundred findings between them.
Finding #1 in this repo was the big one: unauthenticated Server Actions. Serious. That one got fixed first.
Finding #2 stopped me for a different reason.
the thing I almost dismissed
The audit flagged CSV formula injection in src/lib/csv.ts. My first reaction, honestly? These are our affiliates. They upload their own registrant data. Not random internet strangers — actual partners in the program.
I nearly wrote "low priority, real-world exploitation unlikely" and moved on.
Then I re-read the threat chain and realized I'd gotten the victim completely wrong.
the actual attack surface
The web server isn't the target.
The finance person who opens the downloaded file is.
Here's the real chain:
- An affiliate submits their
affid(their unique affiliate ID) embedded in an AEvent registrant CSV - We ingest that — no problem so far, it's just data going into Postgres
- We export a payout CSV via
Papa.unparse()withaffid,email, andwebinarwritten raw - Finance downloads
payout-detail-2026-06.csvand opens it in Excel - If
affidis=cmd|'/C calc'!A0… Excel runs it
That's it. The attack crosses the web-to-spreadsheet boundary. And finance staff opening audit exports in Excel every single month is about as guaranteed as gravity.
the fix — literally 10 lines
// src/lib/csv.ts
function sanitizeCell(value: string): string {
return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
}
Prefix any string that starts with =, +, -, @, TAB, or CR with a single quote '. Excel and Sheets see that leading quote and treat the cell as literal text. Done.
The affid, email, and webinar columns all flow through sanitizeCell() now. The numeric paid_leads column stays untouched — no need to sanitize numbers, they can't trigger formulas.
One helper, three call sites. Shipped.
why this actually matters to me
It's the same pattern I keep bumping into — encode at the output boundary, not at the input boundary.
I sanitize at read time and call it a day. But "clean when it came in" says nothing about what it means in a new context. A string that's safe in a database, safe in a JSON API, safe in a rendered div — that same string starting with = is a formula trigger the moment it lands in a spreadsheet cell.
Same shape as href injection. Same shape as XSS. Different sink, same rule: sanitize where you write, based on where you're writing to.
If you're using Papa.unparse(), csv-stringify, or any other serializer with user-supplied fields, add the one-line check. Not because you distrust your affiliates. Because you know what context you're writing into — and they don't control what happens next.
P.S. The chars to catch:
=,+,-,@, and the less-obvious ones — TAB (\t) and carriage return (\r), which some older Excel versions also interpret as formula triggers. The regex/^[=+\-@\t\r]/covers all of them.