Production AIGuide

LLM CSV Exports: Stop Spreadsheet Formula Injection

Give model-written cells a fixed text contract, preserve rejected originals, and separate file tests from spreadsheet acceptance.

Published: · Reviewed:

Dark square cards move along a cyan-lit conveyor through a metal gate toward an empty grid; a side chute drops one card into an amber-lit tray.
Validate each generated value before it enters the spreadsheet export; retain rejected originals separately.

1. Separate CSV structure from cell interpretation

Your assistant summarizes support tickets into three columns. A generated summary begins with =1+1. The CSV writer correctly surrounds it with quotes, but the spreadsheet receives the cell value after parsing those quotes. Valid CSV structure does not establish a text cell type. OWASP describes this formula-injection boundary, including risky prefixes and differences between consumers.

Build an export policy in application code. Asking the model to avoid formulas is an instruction, not the enforcement mechanism. This guide implements rejection rather than rewriting suspicious text. It extends the output boundary in AI Guardrails for Developers; HTML needs the different controls described in LLM HTML Output: Sanitize Before Rendering.

2. Define the text-only contract

The schema is exactly record_id, summary and source_label, in that order. Headers are application constants. Every cell must be a string; an empty string is allowed. Numbers, booleans, nulls, arrays and objects are rejected. The Python CSV documentation describes automatic writer conversions, including null-to-empty conversion. Reject types before serialization so those conversions cannot erase your intended distinctions.

Our deliberately conservative policy rejects initial whitespace, an initial equals, plus, minus or at sign, and an initial apostrophe. It inspects an NFKC-normalized copy to catch the listed full-width formula prefixes, but writes the unchanged original when accepted. It also rejects Unicode control, format, surrogate, private-use and unassigned characters anywhere, except a line feed after the first character. A leading line feed is rejected as whitespace. These extra restrictions are policy choices; we do not claim that every rejected value would execute.

OWASP lists full-width variants and whitespace/control prefixes, with behavior depending on the consumer and locale. Our policy intentionally rejects legitimate -42, +44, an indented paragraph, tabbed prose and apostrophe-led text. Interior line feeds remain useful in summaries and receive a round-trip test. A cell containing text,=1+1 is accepted because the comma must remain inside that cell under the fixed dialect. That makes correct serialization part of the boundary too. The same applies to line feeds: the prefix check inspects only the start of the cell, so a\n=1+1 also passes. OWASP warns that separators and quotes can put a dangerous character at the beginning of a new cell. If a route you support ignores quoting and splits on delimiters or lines, reject those characters as well or change the route.

3. Preserve originals and validate the complete batch

Save this first block as csv_export.py. Call it with a materialized list of JSON-compatible records and fresh server-controlled paths. Keep the raw JSON outside your download directory, under the original data's access and retention rules. Rejected content belongs there for authorized review, not in a second spreadsheet export.

The archive is written before validation. A rejected batch therefore keeps its original records but creates no CSV. All rows must pass before the downloadable file is opened. Exclusive creation refuses an existing path. This example buffers the document in memory: set request-size, record-count and cell-length limits around it in your application. A production delivery path also needs temporary-file publication and cleanup for disk or process failure; validation success does not make filesystem writes atomic.

import csv
import io
import json
import unicodedata
from pathlib import Path

FIELDS = ("record_id", "summary", "source_label")


def text_cell(value):
    if type(value) is not str:
        raise ValueError("cells must be strings")
    # Inspect a normalized copy; return the original, unchanged string.
    probe = unicodedata.normalize("NFKC", value)
    if probe and (probe[0].isspace() or probe[0] in "=+-@'"):
        raise ValueError("disallowed cell prefix")
    for char in value:
        # Interior LF is allowed; tabs, CR, format controls and surrogates are not.
        if unicodedata.category(char).startswith("C") and char != "\n":
            raise ValueError("disallowed control character")
    return value


def export_records(records, raw_path, csv_path):
    # Caller supplies JSON-compatible records and fresh, trusted server paths.
    # Archive outside the download directory, even if validation rejects the batch.
    with Path(raw_path).open("x", encoding="utf-8") as archive:
        json.dump(records, archive, ensure_ascii=True, indent=2)
    checked = []
    for record in records:
        if type(record) is not dict or set(record) != set(FIELDS):
            raise ValueError("record must match the fixed schema")
        checked.append([text_cell(record[field]) for field in FIELDS])
    # Build the complete document before creating the downloadable file.
    buffer = io.StringIO(newline="")
    writer = csv.writer(buffer, delimiter=",", quotechar='"',
                        doublequote=True, quoting=csv.QUOTE_ALL,
                        lineterminator="\r\n")
    writer.writerow(FIELDS)
    writer.writerows(checked)
    with Path(csv_path).open("x", encoding="utf-8", newline="") as output:
        output.write(buffer.getvalue())

The dialect is UTF-8 without a BOM, comma-separated, double-quoted with doubled embedded quotes, and CRLF record endings. QUOTE_ALL provides predictable quoting, not formula protection. Both file directions use the documented newline="" convention. This contract does not permit model-generated headers or delimiter choices.

4. Run adversarial tests with an exact answer

Save the next block beside the writer as test_csv_export.py, then run the command below. The accepted values cover empty cells, leading zeros, date-like and scientific-looking text, commas, semicolons, quotes, an interior newline and Unicode. The rejected values cover ASCII and full-width prefixes, leading spaces, nonbreaking and ideographic spaces, tabs, carriage returns, a BOM, zero-width text, a NUL, apostrophe wrappers and wrong types. The formulas are inert strings in Python; none calls a network service.

import csv
import json
import platform
import tempfile
from pathlib import Path
from csv_export import FIELDS, export_records, text_cell

accepted = ["", "plain text", "00123", "1E3", "2026-11-07",
            'comma, semicolon; and "quotes"', "line one\nline two",
            'text,=1+1', 'text";=1+1', "café", "full-width A"]
rejected = ["=1+1", "+1+1", "-42", "@SUM(1)", "=1+1", "+1",
            "-42", "@SUM(1)", " =1+1", "\t=1+1", "\r=1+1",
            "\n=1+1", "\u00a0=1+1", "\u3000=1+1", "\ufeff=1+1",
            "\u200b=1+1", "plain\ttext", "plain\rtext", "plain\x00text",
            "'=1+1", "\ud800", None, 42, True, ["text"], {"text": "x"}]
checks = 0
for value in accepted:
    assert text_cell(value) == value
    checks += 1
for value in rejected:
    try:
        text_cell(value)
    except ValueError:
        checks += 1
    else:
        raise AssertionError(repr(value))

with tempfile.TemporaryDirectory() as folder:
    root = Path(folder)
    records = [dict(zip(FIELDS, (f"r{i}", text, "synthetic")))
               for i, text in enumerate(accepted)]
    export_records(records, root / "raw.json", root / "export.csv")
    with (root / "export.csv").open(encoding="utf-8", newline="") as file:
        rows = list(csv.reader(file, delimiter=",", quotechar='"', strict=True))
    assert rows == [list(FIELDS)] + [[r[k] for k in FIELDS] for r in records]
    assert all(len(row) == 3 for row in rows)
    assert json.loads((root / "raw.json").read_text(encoding="utf-8")) == records
    checks += 3
    bad_records = [{"record_id": "r", "summary": "text"},
                   {**records[1], "extra": "unexpected"},
                   {**records[1], "summary": "=1+1"},
                   {**records[1], "summary": None}]
    for i, bad in enumerate(bad_records):
        raw, output = root / f"bad{i}.json", root / f"bad{i}.csv"
        try:
            export_records([records[0], bad], raw, output)
        except ValueError:
            assert not output.exists()
            assert json.loads(raw.read_text(encoding="utf-8")) == [records[0], bad]
            checks += 1
        else:
            raise AssertionError("invalid batch accepted")
print(f"Python {platform.python_version()} / {platform.system()}")
print(f"PASS: {checks} checks; {len(accepted)} accepted cells; "
      f"{len(rejected)} rejected cells; 4 rejected batches")
print("Spreadsheet acceptance: NOT TESTED BY THIS SCRIPT")
python test_csv_export.py

The observed run on November 7, 2026 used Python 3.14.2 on Windows and exited with code 0:

Python 3.14.2 / Windows
PASS: 44 checks; 11 accepted cells; 26 rejected cells; 4 rejected batches
Spreadsheet acceptance: NOT TESTED BY THIS SCRIPT

The matrix comparison checks every header and cell against the original, in order. The width check expects three columns for every parsed record, including the record with an embedded newline. Counting physical lines would give the wrong result. The four invalid batches check missing keys, extra keys, a formula prefix and null: each retains its archive and produces no CSV, even with a valid first row.

This proves the listed serialization and rejection outcomes through Python's writer and reader. It does not observe a spreadsheet cell type. Nor does it establish complete coverage of Unicode transformations or your users' import routes. Add your own troublesome records to both sides of the suite, retaining the distinction between an intentionally rejected input and corrupted output.

5. Test the actual spreadsheet route

The named target for this protocol is LibreOffice Calc. The Text Import documentation, labeled LibreOffice 26.8 Help, provides the settings below. Calc was unavailable in this writing environment, so no installed version, import result or save/reopen result is claimed. Record your actual build rather than treating the help version as a test result.

  1. Record the Calc version/build, operating system, locale and date. Create an export using the accepted synthetic rows above. Record its hash and the expected three-column cell matrix.
  2. Use File → Open and select Text CSV. Set UTF-8, comma as the sole separator and a double quote as string delimiter. Disable merging delimiters and trimming spaces. Enable Format quoted field as text, disable Evaluate formulas, and explicitly select Text for all three columns.
  3. Disable special-number and scientific-notation detection. Those switches alone are insufficient: the documentation says decimal conversion can remain enabled. Explicit Text columns are part of this protocol. Record the options actually present in your build, with a screenshot.
  4. Check three columns and eleven data records plus the header. Inspect underlying cell contents and types, not just their display: the identifier 00123 must retain its zeros, 1E3 must stay that text, and the date-like value must stay text. Confirm zero formula cells, embedded quotes and the interior newline against the expected matrix.
  5. Save a separate CSV copy, recording encoding, separators, quote settings and any format warnings. Close it, then reopen through the same explicit Text CSV settings. Repeat the cell-content, type and zero-formula checks. Parse the saved copy with Python's reader and compare its complete matrix with the original expected matrix.
  6. Record pass/fail for each stage and attach the saved artifact. If users also double-click CSVs, paste cells or use another spreadsheet, test that route separately. A failure blocks that route until you change the workflow or export contract and repeat the test.

Use harmless synthetic data for this acceptance run. The writer's rejected formula strings stay in the Python policy tests; the spreadsheet receives accepted cells. If you need an additional application-specific formula-detection control, design it in an isolated test environment using a harmless arithmetic expression, with separate expectations. No external-data or network-capable formula is needed.

6. Choose a different contract when text is not enough

An analyst may need negative amounts or strings beginning with an apostrophe. Do not silently trim or strip them until they pass. Keep the archived original and return an actionable rejection, such as the record position, field and policy reason, without dumping private cell contents into general logs. Then decide whether the field needs a reviewed numeric schema or a different delivery format with explicit cell types. That is a new contract with its own tests.

OWASP warns that Excel save/reopen can undo quote or escape protections and says no CSV sanitization strategy covers every spreadsheet and downstream consumer. Adding an apostrophe is therefore not a universal fix. This writer adds no prefix. It trades convenience for an auditable rejection rule, while the named application protocol remains a required acceptance step before release.

FAQ

Do double quotes stop CSV formula injection?

No. CSV quoting controls field boundaries. It does not define how a spreadsheet interprets the resulting cell value. Validate cell content and test the intended import route.

Is an apostrophe a universal CSV safety fix?

No. OWASP warns that Excel saving and reopening can undo escaping protections. This example rejects apostrophe-prefixed values instead of adding an escape prefix.

Why does the writer reject negative numbers?

The example accepts text cells and rejects a leading minus sign, including negative numeric-looking text. Preserve the original and use a separately designed numeric contract if the value is needed.

What does a passing CSV round trip prove?

It proves that the listed test values retain their cells and columns through the tested Python writer and reader. It does not prove that a spreadsheet imports them as text or preserves them after saving and reopening.

Primary sources — checked November 7, 2026