Jitsumi
Back to the blog

CSV Quoting Won’t Stop Formula Injection: Export Text Cells in XLSX

CSV quotes do not make spreadsheet formulas safe. Build an explicit-text XLSX export in Python, then verify the stored cell types and test its limits.

A quoted CSV field has no spreadsheet cell type; the same formula-like value is explicitly stored in an XLSX text cell.

A customer name beginning with =1+1 should remain a name when somebody downloads a report. Putting that value in a quoted CSV field does not establish that it will stay text inside a spreadsheet.

For a spreadsheet-facing export, write untrusted text into explicitly typed XLSX string cells. Do not rely on CSV quotes, the .xlsx extension, or an export library's automatic type detection. Keep any deliberate formulas under application control.

This guide is for developers exporting names, notes and other user-controlled strings. It includes a small Python exporter and checks of the resulting file. The example is intentionally text-only; a financial report with numeric columns needs a separate, explicit column schema.

What CSV quoting actually protects

CSV quoting describes where a field starts and ends. It lets a value contain a comma, quote or line break without accidentally becoming several fields. Python's CSV writer documentation describes these formatting rules; they are not instructions assigning a spreadsheet cell type.

Here is a harmless demonstration you can run without opening a spreadsheet:

import csv
from io import StringIO

buffer = StringIO(newline="")
csv.writer(buffer, quoting=csv.QUOTE_ALL).writerow(["=1+1"])
print(repr(buffer.getvalue()))
print(next(csv.reader(StringIO(buffer.getvalue())))[0])

Its output is:

'"=1+1"\r\n'
=1+1

The CSV writer quoted the field correctly. The reader recovered the original value, including its leading equals sign. This is a test of CSV parsing, not a test of formula execution in Excel or another application.

The next interpreter matters. OWASP's CSV injection guidance explains how spreadsheet applications can interpret exported, untrusted values as formulas. It also warns that commonly suggested escaping can stop working after a CSV is saved and reopened. Quoting every field is necessary in some serialization policies, but it is not a general formula-injection defence.

Do not “fix” this by deleting every suspicious character from your database. A phone number may legitimately begin with +, and a note may discuss a formula. Decide how each export represents those values at the point where it is created.

Give each column a meaning before choosing the writer

The useful distinction is between data that merely looks like a calculation and a calculation the application deliberately creates. For a mixed report, make that decision column by column:

Column meaning

Export decision

Customer name, note, phone number or identifier

Preserve as an explicit string, including leading zeros and formula-like prefixes.

A numeric quantity defined by the application schema

Validate its type, range and precision requirements, then write a number intentionally.

A calculated total controlled by the application

Use a separate, reviewed formula-writing path; do not pass arbitrary user text to it.

This is an editorial design recommendation, not a requirement to make every report text-only. A numeric total should still be usable as a number. Conversely, the identifier 000123 should not become 123 just because a writer can parse it.

XlsxWriter's worksheet API makes the distinction important: generic write() can interpret a string beginning with = as a formula, and a supported URL string as a hyperlink. write_string() explicitly writes a string instead. Changing the output extension without checking the writing calls can preserve the original problem.

For untrusted text columns, use the explicit method. Also disable automatic formula, URL and number conversion in the workbook options as a precaution for other generic writing calls. The workbook options reference documents those switches. They do not prevent a programmer from deliberately calling a formula-writing method, so they are not a workbook-wide security boundary.

Build a small, text-only XLSX export

Use Python 3.10 or newer and a disposable project directory. Install the two libraries into the same environment that will run the scripts:

python -m pip install XlsxWriter openpyxl

XlsxWriter creates the export; openpyxl is used for the independent readback below. In an existing application, follow its dependency constraints and upgrade process rather than changing its environment casually. If installation succeeds but importing fails, Jitsumi's guide to checking which Python interpreter runs your script explains the common environment mismatch.

Save the following as export_text.py. It accepts records containing exactly two strings, Name and Note. The 1,000-row and 10,000-character caps are application limits chosen for this small example, not Excel's maximum sizes.

"""Small, text-only export. Python 3.10+; XlsxWriter required."""
from collections.abc import Sequence
from io import BytesIO

import xlsxwriter


def build_export(rows: Sequence[tuple[str, str]]) -> bytes:
    # Application limits, not Excel's maximum worksheet/cell sizes.
    if len(rows) > 1_000:
        raise ValueError("Export exceeds the 1,000-row application limit")
    for row in rows:
        if len(row) != 2:
            raise ValueError("Each record must contain Name and Note")
        for value in row:
            if not isinstance(value, str):
                raise TypeError("Both fields must be strings")
            if len(value) > 10_000:
                raise ValueError("A field exceeds the 10,000-character limit")
            value.encode("utf-8")  # Reject unpaired surrogate code points.
            if any((ord(c) < 32 and c not in "\t\n")
                   or c in "\ufffe\uffff" for c in value):
                raise ValueError("Unsupported control character in a field")

    buffer = BytesIO()
    options = {
        "strings_to_formulas": False,
        "strings_to_urls": False,
        "strings_to_numbers": False,
    }
    with xlsxwriter.Workbook(buffer, options) as workbook:
        sheet = workbook.add_worksheet("Export")
        header = workbook.add_format({"bold": True})
        text = workbook.add_format({"text_wrap": True, "valign": "top"})
        sheet.set_column(0, 0, 24)
        sheet.set_column(1, 1, 48)
        sheet.freeze_panes(1, 0)
        for r, row in enumerate([("Name", "Note"), *rows]):
            for c, value in enumerate(row):
                result = sheet.write_string(r, c, value, header if r == 0 else text)
                if result != 0:
                    raise ValueError(f"Cell ({r}, {c}) was not written: {result}")
    # Nothing is returned unless validation, writing and close all succeed.
    return buffer.getvalue()


if __name__ == "__main__":
    from pathlib import Path
    data = build_export([
        ("=1+1", "Keep the name literal"),
        ("000123", 'Comma, quote " and\na second line'),
        ("+65 6123 4567", "Phone number, not a calculation"),
        ("https://example.invalid/", "Text, not a clickable link"),
    ])
    Path("text-export.xlsx").write_bytes(data)
    print("Wrote text-export.xlsx")

Run it from that disposable directory:

python export_text.py

The demonstration writes text-export.xlsx in the current directory, replacing an existing file with that name. Keep it away from a valuable workbook. The four records are deliberately awkward: formula-like text, an identifier with leading zeros, a phone number and a URL-shaped string.

The core protection is the write_string() call for every text value. Text wrapping only affects presentation; it is not what establishes the stored type. The code also checks each write result instead of accepting an unsuccessful or truncated write silently.

Keep the input contract explicit

This example accepts tabs and line feeds but rejects carriage returns and other C0 control characters. It also rejects unpaired surrogate code points and two invalid XML characters. Those restrictions define the example's supported input; they are not a universal list of dangerous formula prefixes.

A real application's newline policy may differ. Decide whether to reject, preserve or deliberately normalize an unsupported value before export, and explain any transformation to the caller. Do not silently strip characters from customer data while claiming exact preservation.

The function returns bytes only after validation, writing and workbook closure succeed. Its caller should handle a raised exception and withhold the failed export, rather than returning a success response or emailing an incomplete file. Authentication, row-level authorization, total request-size limits and download controls belong outside this small function.

Verify the stored cells, not just the displayed result

A workbook that shows 2 might contain a number, a formula or a cached formula result. Microsoft documents the distinction between a formula's <f> element and its cached value. Looking only at displayed values is therefore a weak test of whether formulas were written.

Save this second script as verify_export.py beside the first. It builds a fresh export in memory, checks the stored values and cell types, then inspects its worksheet XML:

from io import BytesIO
from zipfile import ZipFile
import xml.etree.ElementTree as ET

from openpyxl import load_workbook
from export_text import build_export

rows = [
    ("=1+1", "Literal formula-like text"),
    ("000123", "Leading zeros matter"),
    ("https://example.invalid/", "Not a hyperlink"),
]
blob = build_export(rows)
book = load_workbook(BytesIO(blob), data_only=False)
try:
    sheet = book["Export"]
    for r, record in enumerate(rows, start=2):
        for c, expected in enumerate(record, start=1):
            cell = sheet.cell(r, c)
            if (cell.value != expected or cell.data_type != "s"
                    or cell.hyperlink is not None):
                raise RuntimeError(f"Unexpected cell: {cell.coordinate}")
finally:
    book.close()

ns = {"s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
with ZipFile(BytesIO(blob)) as package:
    for name in package.namelist():
        if name.startswith("xl/worksheets/") and name.endswith(".xml"):
            root = ET.fromstring(package.read(name))
            if root.findall(".//s:f", ns) or root.findall(".//s:hyperlink", ns):
                raise RuntimeError(f"Unexpected formula or hyperlink: {name}")
print("Verified: literal strings; no worksheet formulas or hyperlinks")

Run it with python verify_export.py. For the supplied exporter, it prints:

Verified: literal strings; no worksheet formulas or hyperlinks

Keep data_only=False for this inspection: the check needs to see stored formulas, not only cached results. This test checks the file our exporter just produced; it is not a scanner for arbitrary uploaded workbooks or proof that every possible workbook feature is absent.

In a report that intentionally contains calculations, replace the blanket “no formulas” rule with an exact allowlist of trusted formula cells. Keep user-controlled columns outside that path, and test both the formula locations and their contents.

What the local tests established

The exporter was tested on Linux with Python 3.13.5, XlsxWriter 3.2.9 and openpyxl 3.1.5. These identify the test environment, not the newest available releases.

Nine automated checks passed. They covered CSV quoting, explicit string round-tripping, absence of worksheet formulas and hyperlinks, the contrasting behaviour of default generic writing, input limits, invalid input and a header-only export.

The accepted fixture contained 14 strings: ordinary and full-width formula-like prefixes, an array-formula-looking string, tab- and line-feed-prefixed text, a URL, leading zeros, commas and quotes, Japanese text with an emoji, an empty string and a literal escape-shaped token. Every accepted fixture value read back unchanged as a string. The generated package contained no external-link parts.

Carriage returns are outside the final example's accepted input. During development, an openpyxl readback of a carriage-return fixture returned an escaped representation rather than the original string; the final contract rejects that case instead of promising a round-trip that the test did not establish.

These are file-level checks, not desktop spreadsheet compatibility tests. Excel and LibreOffice were not launched for this example. Before shipping your own export, test the actual application versions and import route your users rely on. Check the harmless =1+1 sample, leading-zero identifiers and multiline notes, then save and reopen the workbook.

When a consumer insists on CSV

Sometimes a downstream importer accepts only CSV. Separate that machine-facing format from the spreadsheet-facing download rather than pretending one representation serves every consumer safely.

For a machine import, document the delimiter, encoding, quoting and field meanings, and test against that importer. Keep the correctly serialized original values when its contract requires them. Do not describe the same raw file as safe to double-click in any spreadsheet application.

For a human-facing CSV, choose a mitigation for a named application and workflow, and test the whole path. OWASP describes prefix-based approaches but explicitly notes their compatibility and data-mutation trade-offs. It states that no single CSV sanitization strategy is safe for all spreadsheet applications and downstream consumers. Adding a tab or apostrophe can change what the next program receives; that change needs an agreed contract.

Saving the typed XLSX export as CSV is also a new boundary: CSV does not carry the original workbook's cell types. A later import must make its own interpretation decisions. Include that conversion in acceptance testing when it is part of the supported workflow.

Put the check at the export boundary

Keep a small regression fixture beside the exporter and run it when changing the writing code, library version or report schema. Use harmless formula-like strings; there is no need for a network request or an operating-system command to test whether a cell was stored as a formula.

For this text-only report, the release condition is concrete: accepted values are preserved, every exported data cell is a string, no worksheet formula or hyperlink is introduced, and failed writes do not become successful downloads. Test the recipient workflow separately and keep those results tied to its actual application version.

The goal is not to make suspicious text look less suspicious. It is to ensure that text remains data in the artifact you hand to the reader.

Sources and further reading

Documentation and local test evidence were checked on 31 October 2026.

Share this post