The second attack surface in a ticket export

An export has two attack surfaces. The server writing the file is the guarded one. The program that opens it reads a cell's first character as an instruction.

Two glass export surfaces frame a pointed cell shard glowing orange

An export has two attack surfaces. The first is the server that builds the file, and it is the one everybody guards. The second is the program that opens it, and quoting a field correctly does nothing for that one, because a spreadsheet reads the first character of a cell as an instruction.

Two surfaces, and only one of them is the server’s

A CSV standard is a parsing contract. It says what to do when a value contains a comma, a double quote or a line break: wrap the field, double any quote inside it, and the row survives the trip. Every export writer implements that, and a team that implements it correctly can watch its file open anywhere with the columns intact.

None of it is about the second surface. The program at the other end is not reading a document, it is building a sheet, and before it stores a cell it decides whether that cell holds a value or an instruction. It decides from the first character. A cell reading =1+1 contains no comma, no quote and no line break, so the parsing contract has no opinion about it: the field is written out unquoted, correct by every rule the standard states, and the sheet evaluates it on open.

The two guards therefore do different jobs, and one is not a relaxed version of the other. Quoting protects the file. Neutralizing protects the person who opens it.

Where a stranger’s text becomes the title

For a feedback tool this matters more than it does for a report generator, because nobody on the project writes the text.

A widget on a public page takes a report from whoever loaded that page. There is no account behind the submission, and unless the host page vouches for somebody, nothing about the person is proved: it carries the project’s public key, which sits in the page source by design, and the endpoint answers cross-origin because an embedded widget cannot work otherwise. What bounds it is a size cap and a rate limit, the same shape of ceiling the host isolation contract puts on everything else that tag does.

Then the words move on their own. For a report arriving this way, nobody on the team types the title: the server trims what was submitted, takes its first line and cuts it to a fixed length. Three months later somebody in support filters the quarter, presses export and opens the file, and the reporter’s own first line is sitting in one of the leading columns.

What arrivesWhat bounds it
A report from anybody who can load the pagethe project’s public key, which the page publishes
Repeated submissions from one session or addressten an hour
The body of the report10,000 characters
The title of the ticketthe first line of that body, cut at 80 characters

None of those bounds is about content. They cap how much and how often, which is the right job for a rate limit and the wrong tool for this one: a formula that opens a URL fits comfortably inside every row.

Escaping and neutralizing are different jobs

The characters a CSV standard forces quoting on are the comma, the double quote, the carriage return and the line feed, and that set exists to stop a row from splitting. The set a spreadsheet treats as the start of a formula is a different one, and the two overlap only in part: some formula characters do force quoting, and an equals sign does not. That is the entire mechanism, and it is why a correct exporter can be a dangerous one. A cell that is nothing but a formula satisfies every rule the standard states, arrives unquoted, and runs.

The answer is a second pass over the leading character of a text cell, run before the quoting decision. What makes it interesting is what that pass has to refuse to do.

Cell textWhat a correct exporter doesWhy
=1+1neutralize it before it reaches the sheetan equals sign triggers none of the quoting the standard forces
a=bleave it exactly as writtenonly the first character decides, and treating every = as a formula rewrites ordinary prose
-42, held as a numberleave it a number, unquoteda negative number opens with a formula character, and prefixing it turns a numeric column into text

OWASP’s page on CSV injection offers, among other approaches, a sanitization applied to each field of the file: wrap it in double quotes, prepend a single quote, escape the quotes inside. Applied to every field as written, the third row above is what that costs, and the failure is silent. Nothing errors, the file opens, the columns look right, and the total at the bottom of a numeric column comes back as zero because every value above it is now a string. An export can be safe and useless at the same time, and the person who finds out is the one who trusted the number.

Who gets to press export

Reading one ticket and downloading every ticket are separate acts here, and the permission says so.

Opening the board is the lowest role that has any access to a project at all. The export sits one rank above it and then asks a second question, about the plan, before a single row is selected. The reasoning is that bulk egress of personal data is a different operation from the per-ticket reads somebody with read access already performs, however many of them they perform.

The order of those two checks carries its own argument. The role check runs before any query, so a refusal cannot teach somebody outside the project that the project exists: they get the answer they would get for a project nobody ever created. The entitlement check runs after it and still ahead of the first row, so a plan refusal lands on an empty response rather than partway through a file of personal data. What the security page covers is the same data arriving; this is the same data leaving.

The question that transfers to any other tool is whether it draws that line at all. Where one role both opens a ticket and downloads ten thousand of them, an export is being treated as a read that happens to be large.

What escaping at export time does not give you

The larger limit belongs to the spreadsheet rather than to any exporter. The OWASP page linked above warns that Excel may remove quotes or escape characters when a file is saved and re-opened, so previously escaped formulas can become active again. A file that has been round-tripped through a spreadsheet is not a sanitized file, whoever wrote it originally. Neutralizing at export time narrows the second surface and does not close it.

That page also maintains the character class itself, width variants included, and it is the list to audit an exporter against rather than one to transcribe into an article. It ends on the sentence worth carrying away: there is no universal CSV sanitization strategy that is safe for every spreadsheet application and every downstream consumer. Its own Excel-resistant advice is a leading tab inside the quoted field, and other guidance treats a leading tab as the thing to neutralize. Whoever picks between them is picking which consumer to protect.

What a neutralized value carries afterwards is our own trade to declare. The marker is a character in the file, so anything reading the export as data sees it, and how a given spreadsheet chooses to display it is that program’s own business. It appears only on the values that needed it, which is the awkward half for whoever automates: the leading character has to come off conditionally, on the cells carrying it, rather than uniformly down a column. We took that trade deliberately. A marker somebody has to strip is the cheaper of the two failures, and the expensive one runs on their machine.

Common questions

Can a bug report execute code when I open the CSV export?

A spreadsheet decides whether a cell holds a value or an instruction by reading its first character, so text somebody typed into a public feedback form can arrive as a formula. Neutralizing the leading character of a text cell stops that cell from running, and correct quoting on its own does not.

Why does my CSV export have an apostrophe in front of some values?

The apostrophe is added at export time so a cell beginning with a formula character is not evaluated when the file is opened. It is a character in the file, so any script reading the export as data sees it and has to strip it from the values that carry it, and how a given spreadsheet chooses to display it is that program's own business.

Is escaping quotes in a CSV enough to make an export safe?

Quoting protects the structure of the file, so a comma or a line break inside a value cannot split the row apart. Whether the spreadsheet then runs the cell is a separate question: a value that is entirely a formula can satisfy every quoting rule and still be evaluated on open.

Who should be allowed to export tickets to CSV?

Downloading every ticket is bulk egress of personal data rather than a read, so the permission belongs a rank above the one that opens a single ticket. Running the role check before any query also stops a refusal from telling a stranger that the project exists.

How should a script consume a CSV that has been protected against formulas?

Strip the leading neutralizing character from text columns before parsing, and expect numbers and dates to arrive untouched. A guard applied to numeric columns would turn them into strings, which is why a correct exporter leaves those columns alone.

Does saving a CSV in Excel and reopening it keep the protection?

OWASP warns that Excel may remove quotes or escape characters when a file is saved and re-opened, so previously escaped formulas can become active again. Protection applied when the file was written is therefore not the last line of defence for a file that has been round-tripped.

The Notarus team

The people who build Notarus. We write up the decisions behind the product when the reasoning is worth more than the changelog line.

Keep reading

Reports with the evidence attached.

Start free, one script tag.

<script src="…/widget.js" data-key="pb_pub_…">