CSV Formula Injection in AI Exports: Why Quoting Is Not Enough

CSV Formula Injection in AI Exports: Why Quoting Is Not Enough

Todd Mercer

A coral fold rests within a cream vessel beside a free cobalt curl, representing literal data and unintended activation.

CSV Formula Injection in AI Exports: Why Quoting Is Not Enough

Quoting a CSV field protects its syntax. It does not tell a spreadsheet application to treat the field as literal text. An AI-generated research report, support summary or lead list can carry formula-like content from an untrusted source into a human reviewer's spreadsheet.

An XLSX extension is not enough either. In our September 30, 2026 test, ordinary openpyxl assignment converted two harmless =1+1 strings into formula cells. Assigning explicit string types preserved all 13 test cells as literal strings, with no formula XML nodes. This was a file-format test, not an Excel security certification.

The practical decision is to separate three requirements: a parseable export, unchanged values, and non-executable cell types. Then validate the actual spreadsheet application that will receive the file.

What We Tested

We ran one synthetic fixture through four export paths using Python 3.13.14 and openpyxl 3.1.5 in Aident Loadout's Code Interpreter. The fixture contained 12 harmless values and one untrusted header. We did not open a spreadsheet application or execute a formula.

The header was =1+1. The values included leading equals, plus, minus and at-sign characters; tab, carriage return and line feed prefixes; a fullwidth equals character; ordinary text; a quoted comma; an embedded newline; and #N/A. Including the header matters: a user-controlled column label is still untrusted input.

For CSV, we parsed the output with Python's CSV reader and compared values. For XLSX, we reopened the workbook with data_only=False, checked values and cell types, and inspected its ZIP package for formula nodes, external relationship targets, macros and external-link parts.

Export path

Exact values after parsing or reopening

Formula cells

What the test establishes

CSV with every field quoted

13/13

Not measured in a spreadsheet

CSV syntax preserves the formula-like input unchanged

CSV with an apostrophe added to every field

0/13

Not measured in a spreadsheet

The parser retains the added prefix; underlying values change

XLSX with ordinary cell assignment

13/13

2

Exact values can coexist with inferred formulas and one error cell

XLSX with explicit string cell types

13/13

0

All 13 cells remain strings in this workbook and library round trip

All 14 acceptance checks passed, including rejection of a 32,768-character input and a NUL character. Both generated workbooks passed ZIP CRC checks. Neither had external relationship targets, macro parts or external-link parts.

These are deterministic controls for one small fixture. They are not attack-prevalence measurements, application compatibility results or a ranking of spreadsheet products.

Why CSV Quotes Do Not Solve Formula Injection

CSV quoting answers questions such as: does a comma belong inside this field, and does a newline belong inside this value? After a CSV reader removes the syntactic quotes, "=1+1" is still the string =1+1.

Whether that string becomes a formula is a separate decision made by the receiving spreadsheet application. OWASP's CSV Injection guidance describes this boundary, lists relevant leading characters, and cautions that there is no universal sanitization strategy for every spreadsheet application and downstream consumer. Its guidance also addresses changes after saving and reopening files.

Our quoted-CSV control proves the first part: serialization and parsing left the leading equals sign intact. It does not prove what a particular Excel, LibreOffice or Google Sheets version would do. Those applications were outside the experiment.

This is relevant to AI exports because model output can repeat external text. Asking a model not to produce formulas is not an acceptance test for the resulting file. The exporter still needs an explicit policy for untrusted strings, including headings and labels.

A recent concrete example is ihub-apps' July 8 export fix, which addresses formula-like AI text in CSV and XLSX outputs. That source shows the export boundary being maintained in a real application; it is not evidence that every AI exporter is vulnerable or that one mitigation works everywhere.

Why an Apostrophe Is a Data-Policy Decision

Prefixing a value with an apostrophe may be part of a spreadsheet-specific mitigation. It also changes the exported data.

In our second path, every parsed field retained the apostrophe, including ordinary text and the header. None of the 13 parsed values exactly matched its input. We deliberately applied the prefix to every field to make that tradeoff observable; this is not a recommended universal sanitizer.

That distinction matters when a spreadsheet later feeds a database, a billing system or another parser. A visible value that looks acceptable to a reviewer may no longer be the original machine-readable value. Decide whether transformation is allowed before choosing a mitigation, and document the transformation for downstream consumers.

If CSV is mandatory, define the receiving application, import procedure and save/reopen behavior. Use a correct CSV serializer, then test the chosen mitigation in that environment with harmless fixtures. Do not infer a portable security guarantee from the CSV parser alone.

Why Ordinary XLSX Assignment Still Creates Formulas

XLSX can carry explicit cell types, but a library may infer those types from values. In the ordinary-assignment path, the two =1+1 cells became formulas: one header and one data value. #N/A became an error cell. The remaining ten cells were strings.

The openpyxl cell implementation documents the relevant binding behavior. Our installed-version result is the experiment above; the stable documentation is supporting implementation context, not a claim that all versions behave identically.

A value can therefore survive an exact comparison while its meaning changes. Reopening with data_only=False lets this test inspect the formula string rather than a cached result. Checking values alone would miss the boundary failure.

For a new text-only review workbook, our explicit path assigns the value and then sets cell.data_type = 's'. The ordering matters because ordinary assignment performs type binding first. The wrapper rejects excessive length and illegal control characters instead of silently accepting an altered export.

This narrow writer is not a sanitizer for arbitrary existing workbooks. It does not inspect every embedded object or safely preserve a workbook that mixes trusted formulas with untrusted data. If trusted formulas are required, define their cells separately and keep untrusted values out of that allowlist.

Reproduce the Harmless Four-Path Test

Run the following complete fixture in Python with openpyxl available. It creates files in memory, prints a JSON summary, and uses no production data, network requests, macro payloads or external formula targets. Expected acceptance output is passed: 14; generated ZIP hashes can vary between runs because package metadata can change.

import csv
import hashlib
import io
import json
import platform
import zipfile
from xml.etree import ElementTree as ET

import openpyxl
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE

HEADER = '=1+1'
VALUES = ['=1+1', '+1', '-1', '@SUM(1,1)', '\t=1+1', '\r=1+1', '\n=1+1',
          '=1+1', 'ordinary text', 'quote",comma', 'line\nbreak', '#N/A']
ROWS = [[HEADER]] + [[value] for value in VALUES]

def csv_export(prefix):
    out = io.StringIO(newline='')
    csv.writer(out, quoting=csv.QUOTE_ALL).writerows([[prefix + cell for cell in row] for row in ROWS])
    return out.getvalue()

def literal_cell(sheet, row, column, text):
    if not isinstance(text, str):
        raise TypeError('Text-only export requires strings')
    if len(text) > 32767 or ILLEGAL_CHARACTERS_RE.search(text):
        raise ValueError('Reject unsupported length or control character')
    cell = sheet.cell(row=row, column=column, value=text)
    cell.data_type = 's'

def xlsx_export(literal):
    workbook = openpyxl.Workbook()
    sheet = workbook.active
    sheet.title = 'Export'
    for r, row in enumerate(ROWS, 1):
        for c, text in enumerate(row, 1):
            if literal:
                literal_cell(sheet, r, c, text)
            else:
                sheet.cell(row=r, column=c, value=text)
    out = io.BytesIO()
    workbook.save(out)
    return out.getvalue()

def inspect_xlsx(data):
    wb = openpyxl.load_workbook(io.BytesIO(data), data_only=False)
    cells = [cell for row in wb.active for cell in row]
    with zipfile.ZipFile(io.BytesIO(data)) as archive:
        assert archive.testzip() is None
        xml = ET.fromstring(archive.read('xl/worksheets/sheet1.xml'))
        formula_nodes = len(xml.findall('.//{http://schemas.openxmlformats.org/spreadsheetml/2006/main}f'))
        external_targets = []
        for name in archive.namelist():
            if name.endswith('.rels'):
                for relationship in ET.fromstring(archive.read(name)):
                    if relationship.get('TargetMode') == 'External':
                        external_targets.append(relationship.get('Target'))
        executable_parts = [name for name in archive.namelist() if 'vbaProject' in name or name.startswith('xl/externalLinks/')]
    return {'cells': len(cells), 'exact_values': sum(cell.value == row[0] for cell, row in zip(cells, ROWS)),
            'formula_cells': sum(cell.data_type == 'f' for cell in cells),
            'error_cells': sum(cell.data_type == 'e' for cell in cells),
            'string_cells': sum(cell.data_type == 's' for cell in cells),
            'formula_xml_nodes': formula_nodes, 'external_targets': external_targets,
            'macro_or_external_link_parts': executable_parts,
            'sha256': hashlib.sha256(data).hexdigest(), 'bytes': len(data)}

quoted = csv_export('')
apostrophe = csv_export("'")
quoted_rows = list(csv.reader(io.StringIO(quoted, newline='')))
apostrophe_rows = list(csv.reader(io.StringIO(apostrophe, newline='')))
inferred = inspect_xlsx(xlsx_export(False))
literal = inspect_xlsx(xlsx_export(True))
checks = {
    'quoted_csv_round_trip': quoted_rows == ROWS,
    'quoted_csv_leading_equals_retained': quoted_rows[1][0].startswith('='),
    'apostrophe_changes_every_value': all(a[0] != b[0] for a, b in zip(apostrophe_rows, ROWS)),
    'inferred_xlsx_two_formulas': inferred['formula_cells'] == 2,
    'inferred_xlsx_one_error': inferred['error_cells'] == 1,
    'literal_xlsx_exact_13': literal['exact_values'] == 13,
    'literal_xlsx_strings_13': literal['string_cells'] == 13,
    'literal_xlsx_formula_cells_zero': literal['formula_cells'] == 0,
    'literal_xlsx_formula_xml_zero': literal['formula_xml_nodes'] == 0,
    'literal_xlsx_error_cells_zero': literal['error_cells'] == 0,
    'literal_xlsx_external_targets_zero': literal['external_targets'] == [],
    'literal_xlsx_macro_parts_zero': literal['macro_or_external_link_parts'] == [],
}
test_sheet = openpyxl.Workbook().active
for key, invalid in [('reject_oversize', 'x' * 32768), ('reject_nul', 'bad\x00text')]:
    try:
        literal_cell(test_sheet, 1, 1, invalid)
        checks[key] = False
    except ValueError:
        checks[key] = True
assert all(checks.values()), checks
print(json.dumps({'python': platform.python_version(), 'openpyxl': openpyxl.__version__,
    'scope': '12 harmless synthetic values plus one untrusted header; no spreadsheet application or formula execution',
    'quoted_csv': {'exact_values': sum(a == b for a, b in zip(quoted_rows, ROWS)), 'leading_equals_retained': True},
    'apostrophe_csv': {'exact_values': sum(a == b for a, b in zip(apostrophe_rows, ROWS)), 'prefix_retained_after_parser': True},
    'inferred_xlsx': inferred, 'literal_xlsx': literal, 'checks': checks, 'passed': len(checks)}, sort_keys=True))

Turn the Result Into a Delivery Contract

Before delivering a text-only workbook, require exact values for every expected header and cell, string types for untrusted text, no unexpected formula nodes, and the absence of prohibited external relationships or macro parts. Reject unsupported input rather than quietly changing it. Also verify sheet names, expected dimensions and omitted rows for the real export; those requirements were not all acceptance assertions in this small experiment.

Next, open a harmless fixture in the actual receiving application. Inspect its behavior, save, close and reopen it, and check any subsequent CSV conversion. Re-exporting to CSV removes the explicit XLSX string-type contract, so it needs a new acceptance test. OWASP's save/reopen caveat is a reason to test that complete handoff rather than just the first import.

Do not run adversarial formulas against a production workstation to reproduce this article. The harmless values above are enough to expose serialization and type-inference differences without demonstrating exfiltration or command execution.

Run the Fixture Through Aident Loadout

For an agent that needs a hosted Python experiment, first choose its execution boundary. Code Interpreter vs AI Agent Sandbox explains why a data-analysis runtime and a broader workspace are different choices.

Give the agent this bounded task:

Follow https://aident.ai/SETUP.md. Discover the current Code Interpreter execution Action, inspect its schema and preflight the complete synthetic fixture above. Run only that fixture. Return the execution wrapper, Python error, stderr and JSON checks. Do not upload customer data, open a spreadsheet application, execute formulas or send external requests. Accept the result only when the code error and stderr are empty and all 14 checks pass.

Our September 30 call used code_to_execute, timeout: 60 and keep_alive: 0. The exact preflight quoted zero credits for that call; recheck current pricing, packages and schema before another execution. An Action wrapper reporting success is not sufficient if the returned code error or assertions indicate failure.

Before moving from a synthetic experiment to business data, review permission, retention and data-sharing boundaries. Giving an AI agent API access without exposing keys covers the separate credential-access decision. This article does not establish permission to upload a private spreadsheet.

Inspect the Export Test Runtime

Limits, Sources and Refresh Triggers

This experiment establishes a narrow distinction between syntax, value preservation and cell typing. It does not establish universal formula-injection prevention, full workbook security, compatibility across spreadsheet applications, model safety or product conversion. It also does not cover arbitrary inherited workbook features or all Unicode and locale behavior.

Primary sources inspected September 30, 2026:

The complete first-party fixture is inline above. Rerun it when the library, hosted runtime or export policy changes. Add application-specific save/reopen tests when the receiving spreadsheet or import settings change. Expand the corpus before expanding the claim.

About the author

Illustrated editorial avatar of Todd Mercer

Todd Mercer

Todd Mercer is a senior engineer with more than 10 years of experience working on social media platforms and large-scale ordering systems. He graduated from the University of Illinois Urbana-Champaign in computer science. At Aident, he covers technical issues, developer tools, and technology topics.

Home

Home

Home

Integrations

Integrations

Integrations

Vault

Vault

Vault

Audit

Audit

Audit

Arana Grande

Arana Grande

Arana Grande

Free

Free

Free

30-day audit summary

30-day audit summary

30-day audit summary

Daily action-call volume and the latest receipts from the Loadout audit trail.

Daily action-call volume and the latest receipts from the Loadout audit trail.

Daily action-call volume and the latest receipts from the Loadout audit trail.

View Audit

View Audit

View Audit

Loadout usage

Loadout usage

Loadout usage

617 action calls in the last 30 days

617 action calls in the last 30 days

617 action calls in the last 30 days

May 19 - Jun 17

May 19 - Jun 17

May 19 - Jun 17

10 active days

10 active days

10 active days

Less

Less

Less

More

More

More

Recent activity

Recent activity

Recent activity

Latest action-call receipts from connected agents

Latest action-call receipts from connected agents

Latest action-call receipts from connected agents

Apr 23, 09:23 AM

Apr 23, 09:23 AM

Apr 23, 09:23 AM

Shopify

Shopify

Shopify

Creates Or Updates An Asset For A Theme

Creates Or Updates An Asset For A Theme

Creates Or Updates An Asset For A Theme

Success

Success

Success

Apr 23, 09:21 AM

Apr 23, 09:21 AM

Apr 23, 09:21 AM

Shopify

Shopify

Shopify

Update Products Param Product Id

Update Products Param Product Id

Update Products Param Product Id

Success

Success

Success

Apr 23, 08:53 AM

Apr 23, 08:53 AM

Apr 23, 08:53 AM

Shopify

Shopify

Shopify

Update Products Param Product Id

Update Products Param Product Id

Update Products Param Product Id

Failed

Failed

Failed

Apr 22, 22:13 PM

Apr 22, 22:13 PM

Apr 22, 22:13 PM

Shopify

Shopify

Shopify

Create Product Image

Create Product Image

Create Product Image

Success

Success

Success

Apr 22, 22:12 PM

Apr 22, 22:12 PM

Apr 22, 22:12 PM

Shopify

Shopify

Shopify

Create Product Image

Create Product Image

Create Product Image

Success

Success

Success

Connected integration coverage

Connected integration coverage

Connected integration coverage

162

162

162

of 753 accessible connected

of 753 accessible connected

of 753 accessible connected

Callable actions

Callable actions

Callable actions

1,126

1,126

1,126

Vault credentials

Vault credentials

Vault credentials

8

8

8

Explore what's possible

Explore what's possible

Explore what's possible

See all Integrations

See all Integrations

See all Integrations

Google Ads

Google Ads

Google Ads

All available Goolge Ads tools via...

All available Goolge Ads tools via...

All available Goolge Ads tools via...

X (twitter)

X (twitter)

X (twitter)

All available X tools via...

All available X tools via...

All available X tools via...

Github

Github

Github

All available Github tools via...

All available Github tools via...

All available Github tools via...

Notion

Notion

Notion

All available Notion tools via...

All available Notion tools via...

All available Notion tools via...

Slack

Slack

Slack

All available Slack tools via...

All available Slack tools via...

All available Slack tools via...

Firecrawl

Firecrawl

Firecrawl

All available Firecrawl tools via...

All available Firecrawl tools via...

All available Firecrawl tools via...

753 integrations are available for loadouts.

753 integrations are available for loadouts.

753 integrations are available for loadouts.

The one tool

for every tool

your agent needs.

Give any AI agent real capabilities in seconds. Connect 27,000+ tools once, skip the setup headache, and let your agents execute.

Try Aident Loadout

Empower your Codex or OpenClaws to get real jobs done. Connect 27,000+ tools in one prompt, and let your agents deliver real results.

Try Aident Loadout

Empower your Codex or OpenClaws to get real jobs done. Connect 27,000+ tools in one prompt, and let your agents deliver real results.

Try Aident Loadout

Empower your Codex or OpenClaws to get real jobs done. Connect 27,000+ tools in one prompt, and let your agents deliver real results.