CSV Formula Injection in Exports: The One-Apostrophe Fix
A name typed as =HYPERLINK(...) runs when someone opens a CSV export in a spreadsheet. The small cell guard RPOMS uses on both client and server exports, and what it leaves alone.
An export is a file someone will double-click
RPOMS, the operations system I built for a router-refurbishment line, exports the router registry as CSV: serials, models, the names of the people who accepted each router, dates and boxes. Those values are typed by hand on the floor or imported from other people's spreadsheets, as described in Staged CSV Import. And the export is opened in Excel.
A spreadsheet treats a cell that starts with =, +, - or @ as a formula. So a person's name entered as =HYPERLINK(...), or a serial pasted from somewhere with a leading =, runs the moment someone opens the export. This is CSV or formula injection, and a launch-readiness audit in September 2026 flagged it on these exports (F-27). The data stored in the database is fine; the danger is entirely in how it is written out.
The guard
export function csvCell(value: unknown): string {
let text = String(value ?? "");
if (/^[=+\-@\t\r]/.test(text) && !/^-?\d+(\.\d+)?$/.test(text)) {
text = `'${text}`;
}
return `"${text.replace(/"/g, '""')}"`;
}Every cell is quoted, with inner quotes doubled, which handles commas and line breaks in a value. A cell that starts with a formula character gets a leading apostrophe, which tells the spreadsheet to read the cell as the text it is. The apostrophe is invisible in the cell and is dropped if the file is read back in.
Tab and carriage return are in the list too. A value that starts with one of them can smuggle a formula in behind what looks like leading whitespace.
What it deliberately leaves alone
The naive version, prefix anything starting with -, damages data: -5 is a value, not a formula, and prefixing it turns a number into text that no longer sums. So a plain number, optionally negative and optionally decimal, keeps its sign and is left untouched. Tests pin both directions; for example:
| Input | Written as | Why |
|---|---|---|
=HYPERLINK("x") | "'=HYPERLINK(""x"")" | formula neutralised, quotes doubled |
@SUM(A1) | prefixed with ' | formula character |
-5 | "-5" | a signed number stays a number |
| a serial, a date, a name | quoted, otherwise unchanged | ordinary values are not touched |
One guard for client and server
RPOMS has two ways to export the registry: a button that builds the CSV in the browser from what is on screen, and a server route that returns the file as a download. The guard has no server-only import, so both use the same function. Two implementations of an escaping rule will eventually disagree, and the one that is wrong will be the one nobody tested.
The same audit pass made the printable box label escape what it prints, for the same reason in a different format: the value came from a person, and the output is interpreted by something else.
- Quote every CSV cell and double inner quotes.
- Prefix cells starting with
=,+,-,@, tab or carriage return with an apostrophe. - Exempt plain numbers, so negative values stay numeric.
- Use one function for every export path, and test both what it changes and what it leaves alone.
The system is described in the RPOMS case study.
The work behind this
Related
Keep reading
Staged CSV Import: Validating Operational Data Before It Is Written
The office already keeps its records in a workbook. Importing that sheet into a production system means matching its headers loosely, reading its dates the way it wrote them, surfacing duplicates before anything is saved, and never letting the client tell the server what a file contains.
The Test That Calls Every Next.js API Route With No Session
Five API reads in RPOMS typechecked and linted cleanly and were open to anyone. They were found by probing the built app, and now one test globs every route.ts and expects a refusal.
Filling a Word Template in Node.js When Word Splits Your Placeholders
How RPOMS fills the office's own Delivery Order .docx with JSZip: placeholders split across Word runs, a two-pass replace, and an Excel tracker beside it.